3. Kiến trúc Docker Engine
Mục lục
- 1. Bức tranh tổng quan: góc nhìn Ops và Dev
- 2. Docker Engine thực chất gồm những gì?
- 3. containerd, runc và shim hoạt động cùng nhau ra sao?
1. Bức tranh tổng quan: góc nhìn Ops và Dev
Cài xong Docker ở Phần 2 rồi thì sao? Gõ 4-5 dòng lệnh, và trong chưa đầy 1 phút, bạn có ngay 1 ứng dụng chạy cô lập trên máy — đó là toàn bộ trải nghiệm Docker nhìn từ bên ngoài. Phần này đi qua nhanh 2 góc nhìn để thấy rõ trải nghiệm đó: góc nhìn Ops (tải image, khởi chạy container, chạy lệnh bên trong, rồi hủy nó) và góc nhìn Dev (lấy code app từ GitHub, xem qua Dockerfile, containerize app, chạy nó như 1 container). Chỉ cần 1 Docker host có kết nối internet (Linux hoặc Windows, VM laptop, instance cloud, hay bare-metal đều được) là đủ để làm theo.
Góc nhìn Ops
Khi cài Docker, bạn có 2 thành phần chính: Docker client và Docker daemon (còn gọi là "server" hay "engine") — daemon hiện thực Docker Remote API. Trên Linux mặc định, client nói chuyện với daemon qua local IPC/Unix socket tại /var/run/docker.sock; trên Windows là qua named pipe npipe:////./pipe/docker_engine. Kiểm tra client/daemon còn sống và nói chuyện được với nhau bằng:
$ docker version
# Output minh họa
Client: Docker Engine - Community
Version: 24.0.7
API version: 1.43
Server: Docker Engine - Community
Engine:
Version: 24.0.7
API version: 1.43 (minimum version 1.12)
(Output minh họa — cấu trúc và tên field đúng theo lệnh thật, số version cụ thể chỉ mang tính ví dụ.)
Nếu phần Server báo lỗi trên Linux, thử lại với sudo docker version — nếu chạy được, cần thêm user vào group docker cục bộ (xem phần cài đặt trên Linux ở Part 2), hoặc luôn thêm sudo trước mỗi lệnh Docker.
Image: nghĩ đơn giản, 1 Docker image là 1 object chứa OS filesystem + ứng dụng — dân Ops có thể hình dung nó giống 1 VM template (về bản chất là 1 VM đã dừng); dân Dev có thể hình dung nó giống 1 class. Xem danh sách image hiện có:
$ docker image ls
# Output minh họa (host mới cài nên bảng chỉ có dòng tiêu đề, chưa có image nào)
REPOSITORY TAG IMAGE ID CREATED SIZE
Host mới cài sẽ chưa có image nào. Tải image về host gọi là "pull":
$ docker image pull ubuntu:latest
# Output minh họa
latest: Pulling from library/ubuntu
a2318d6c47ec: Pull complete
Digest: sha256:9a0bdde4188b896a372804be2384015e90e3f84906b750c1a53539b585fbbe7
Status: Downloaded newer image for ubuntu:latest
docker.io/library/ubuntu:latest
(Output minh họa — mỗi dòng Pull complete ứng với 1 layer trong image; số layer thật tùy phiên bản image, digest cụ thể chỉ mang tính ví dụ.)
Image chứa đủ 1 phần OS (ubuntu image chứa bản rút gọn của Ubuntu filesystem cùng vài utility quen thuộc) cộng toàn bộ code/dependency cần để chạy ứng dụng nó phục vụ. Mỗi image có 1 ID riêng — có thể tham chiếu bằng ID hoặc bằng tên.
Container: khởi chạy 1 container từ image vừa pull bằng docker container run:
$ docker container run -it ubuntu:latest /bin/bash
root@6dc20d508db0:/#
Cờ -it yêu cầu daemon tạo container ở chế độ tương tác và gắn (attach) terminal hiện tại vào shell của container — shell prompt đổi hẳn vì bạn đang thực sự "ở bên trong" container mới. Chạy ps -elf bên trong container sẽ chỉ thấy vỏn vẹn 2 tiến trình: /bin/bash (PID 1, tiến trình chính được chỉ định lúc run) và chính lệnh ps vừa gọi (tiến trình ngắn hạn, chết ngay khi lệnh kết thúc) — nghĩa là tiến trình sống lâu dài duy nhất bên trong container chỉ là /bin/bash.
Nhấn Ctrl-P Ctrl-Q để thoát ra khỏi container mà không kill nó — quay lại đúng shell của Docker host. Chạy ps -elf lần nữa ở host sẽ thấy rõ số lượng tiến trình trên host luôn nhiều hơn hẳn so với bên trong container.
Xem danh sách container đang chạy:
$ docker container ls
# Output minh họa
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6dc20d508db0 ubuntu:latest "/bin/bash" 2 minutes ago Up 2 minutes happy_curie
(Output minh họa — tên container (cột NAMES) do Docker tự sinh ngẫu nhiên nếu không dùng --name, ID/thời gian cụ thể chỉ mang tính ví dụ.)
Attach lại vào container đang chạy bằng docker container exec:
$ docker container exec -it <tên-hoặc-id-container> bash
root@6dc20d508db0:/#
(Output minh họa — prompt đổi y hệt như lúc docker container run -it, vì thực chất cũng đang mở 1 shell mới bên trong container đó.)
Cú pháp chung: docker container exec -options <tên/ID container> <command>. Thoát lại bằng Ctrl-P Ctrl-Q, rồi dừng và xóa hẳn container:
$ docker container stop <tên/ID container>
<tên/ID container>
$ docker container rm <tên/ID container>
<tên/ID container>
(Output minh họa — cả 2 lệnh đều chỉ echo lại đúng tên/ID container vừa thao tác thành công, không có thêm chi tiết nào khác.)
Góc nhìn Dev
Container luôn xoay quanh ứng dụng. Ví dụ: giả sử bạn đã có sẵn code 1 web app Node.js đơn giản trong thư mục dự án, kèm theo file Dockerfile (1 văn bản thuần mô tả cách build ra 1 Docker image):
$ cd hello-web
$ cat Dockerfile
Ví dụ nội dung Dockerfile của app này:
FROM alpine
LABEL maintainer="[email protected]"
RUN apk add --update nodejs nodejs-npm
COPY . /src
WORKDIR /src
RUN npm install
EXPOSE 8080
ENTRYPOINT ["node", "./app.js"]
Mỗi dòng trong Dockerfile là 1 instruction dùng để build image (chi tiết Dockerfile sẽ nói kỹ hơn ở phần sau). Build image mới từ chính thư mục chứa code + Dockerfile:
$ docker image build -t test:latest .
# Output minh họa (rút gọn)
Sending build context to Docker daemon 12.29kB
Step 1/8 : FROM alpine
---> caf27325b298
---> Running in 98travu38f2a
Step 3/8 : RUN apk add --update nodejs nodejs-npm
---> Running in 3b91fa6a4a35
...
Step 8/8 : ENTRYPOINT ["node", "./app.js"]
---> Running in 6e7c5f8d9a1b
Successfully built 9b91d2ba6599
Successfully tagged test:latest
(Output minh họa — rút gọn, mỗi Step N/8 ứng đúng 1 instruction trong Dockerfile ở trên; ID container tạm/image cụ thể chỉ mang tính ví dụ.)
Kiểm tra image vừa build đã có trong danh sách (docker image ls), rồi chạy thử container từ image đó, map cổng container ra ngoài host:
$ docker container run -d --name web1 -p 8080:8080 test:latest
# Output minh họa
9b91d2ba659912a67f4f95bfb3c8e8a1e3f0e6a5d4c3b2a1908f7e6d5c4b3a2
(Output minh họa — cờ -d chạy nền nên output chỉ trả về đúng ID đầy đủ của container vừa tạo, không attach vào shell như ví dụ -it ở trên.)
Mở trình duyệt trỏ tới DNS name/IP của host, cổng 8080 — sẽ thấy trang web của app vừa containerize. Vậy là đã lấy 1 ứng dụng có sẵn, build thành image, và chạy nó như 1 container.
2. Docker Engine thực chất gồm những gì?
Nói ngắn gọn: Docker Engine là phần mềm lõi tạo và quản lý container — thường được gọi tắt là "Docker" hay "nền tảng Docker". Với ai quen VMware, có thể hình dung nó tương tự ESXi.
Docker Engine thiết kế theo hướng modular, gồm nhiều thành phần có thể hoán đổi (swappable), phần lớn dựa trên chuẩn mở của OCI. Tại thời điểm sách gốc viết, các thành phần chính gồm: Docker client, Docker daemon, containerd, và runc.
💡 Hình dung: Docker Engine giống 1 động cơ xe hơi — động cơ được ghép từ nhiều bộ phận chuyên biệt (intake manifold, throttle body, xi-lanh, bugi, ống xả...) để cùng vận hành chiếc xe. Docker Engine cũng vậy, được ghép từ nhiều công cụ chuyên biệt (client, API, execution driver, runtime, shim...) để cùng tạo và chạy container — hỏng hay lỗi thời bộ phận nào, thay đúng bộ phận đó, không cần đại tu cả cỗ máy.
Từ daemon nguyên khối tới kiến trúc module hóa
Ở phiên bản đầu tiên, Docker Engine chỉ gồm 2 thành phần chính: Docker daemon (1 binary nguyên khối chứa toàn bộ code cho Docker client, Docker API, container runtime, image build...) và LXC (cung cấp cho daemon quyền truy cập vào các building-block nền tảng của container như kernel namespace và cgroup).
Việc phụ thuộc vào LXC là vấn đề ngay từ đầu: LXC chỉ chạy trên Linux — bất lợi cho 1 dự án tham vọng đa nền tảng; đồng thời việc phụ thuộc 1 tool ngoài cho phần cốt lõi như vậy cũng là rủi ro lớn, có thể cản trở tốc độ phát triển. Vì vậy Docker, Inc. tự phát triển libcontainer để thay LXC — 1 tool platform-agnostic, cấp quyền truy cập các building-block container nền tảng của OS. Libcontainer thay LXC làm execution driver mặc định từ Docker 0.9.
Theo thời gian, bản chất nguyên khối của daemon lại tiếp tục là vấn đề: khó đổi mới, chạy chậm dần, và không còn đúng hướng cả hệ sinh thái lẫn chính Docker, Inc. muốn đi. Docker, Inc. bắt đầu 1 nỗ lực lớn để tách daemon thành nhiều tool nhỏ, chuyên biệt, có thể hoán đổi cho nhau và dễ tái sử dụng bởi bên thứ 3 — đi theo đúng triết lý Unix: xây nhiều tool nhỏ chuyên biệt rồi ghép lại thành tool lớn hơn. Quá trình này diễn ra liên tục, và tới nay toàn bộ code execution/runtime của container đã được tách hẳn khỏi daemon, refactor thành các tool nhỏ riêng biệt.
Ảnh hưởng của Open Container Initiative (OCI)
Song song với việc tách daemon, OCI cũng đang định nghĩa 2 chuẩn liên quan container: image spec và container runtime spec — cả 2 được phát hành bản 1.0 vào tháng 7/2017, với sự tham gia đóng góp rất nhiều từ chính Docker, Inc.
Từ Docker 1.11 (đầu 2016), Docker Engine hiện thực các chuẩn OCI này càng sát càng tốt: daemon không còn chứa bất kỳ code container runtime nào nữa — toàn bộ chuyển sang 1 lớp riêng biệt tuân OCI. Mặc định, Docker dùng runc cho việc này.
3. containerd, runc và shim hoạt động cùng nhau ra sao?
Phần trên đã nêu tên 2 thành phần đứng sau daemon: containerd và runc — cùng 1 nhân vật thứ 3 chưa được nhắc tới là shim. Cả 3 phối hợp với nhau theo kiểu phân công rất rõ ràng: 1 đứa quản lý, 1 đứa thi công, 1 đứa trông chừng.
💡 Hình dung: giống 1 dây chuyền bếp nhà hàng. containerd là bếp trưởng nhận order rồi phân việc, đứng suốt ca không rời vị trí. runc là đầu bếp được gọi ra chỉ để nấu đúng 1 món rồi rời bếp ngay khi món ra lò — không đứng lại canh bàn. shim là người bưng món ra bàn khách và đứng lại phục vụ suốt bữa — kể cả khi đầu bếp runc đã đi làm việc khác từ lâu.
Namespace và cgroup: nền tảng giúp container "cách ly" nhau
Trước khi nói runc làm việc thế nào, cần trả lời 1 câu hỏi nền tảng hơn: điều gì thực sự khiến 1 container trông và hoạt động như 1 máy riêng biệt, dù đang chạy chung phần cứng, chung đúng 1 kernel Linux với hàng chục container khác trên cùng host? Câu trả lời nằm ở 2 tính năng có sẵn ngay trong Linux kernel — không phải do Docker tự chế ra: namespace và cgroup. Đây chính xác là 2 thứ runc gọi tới khi "dựng" 1 container.
- namespace lo phần cách ly (isolation): cho container 1 "góc nhìn" riêng về hệ thống, dù bản chất vẫn dùng chung 1 kernel. Có nhiều loại namespace, mỗi loại cô lập đúng 1 khía cạnh — PID namespace (bảng tiến trình riêng, tự đếm từ 1), network namespace (địa chỉ IP, port, route riêng), mount namespace (hệ thống file riêng)...
- cgroup (control group) lo phần giới hạn (limit): quy định container này được dùng tối đa bao nhiêu CPU, RAM, I/O — để 1 container "tham lam" không thể chiếm hết tài nguyên của cả host, ảnh hưởng tới container khác.
Đây chính là lý do ở Phần 1, bên trong container /bin/bash luôn hiện ra là PID 1 — không phải vì nó thực sự là tiến trình đầu tiên chạy trên toàn hệ thống, mà vì PID namespace riêng của container đó tự đếm lại từ 1, tách biệt hoàn toàn khỏi bảng PID thật trên host.
💡 Hình dung: namespace giống vách ngăn phòng trong 1 tòa chung cư — mỗi phòng (container) có cửa riêng, đồ đạc riêng, đứng trong phòng không nhìn thấy phòng bên cạnh, dù cả tòa nhà (kernel) vẫn chỉ là 1. cgroup giống hợp đồng thuê phòng ghi rõ định mức điện nước tối đa mỗi phòng — phòng nào xài vượt định mức sẽ tự bị cắt, không kéo theo phòng khác.
Tự kiểm chứng điều này rất dễ — cùng là 1 tiến trình, nhưng nhìn từ trong container nó luôn là PID 1, còn nhìn từ host nó lại mang 1 con số hoàn toàn khác:
$ docker container run -d --name web1 nginx
$ docker inspect --format '{{.State.Pid}}' web1
# Output minh họa
4128
(Output minh họa — số PID thật thay đổi tùy host đang chạy, nhưng chắc chắn khác 1.)
Bên trong container, docker container exec web1 ps -ef sẽ luôn cho thấy nginx đứng ở PID 1; trong khi lệnh docker inspect vừa chạy ở host lại cho ra PID 4128. Cùng 1 tiến trình, 2 con số hoàn toàn khác nhau, tùy đang đứng "bên trong" hay "bên ngoài" namespace mà nhìn vào — đây chính là namespace đang làm đúng việc của nó, và cũng chính xác là điều runc dựng lên ở bước 5 khi "giao tiếp trực tiếp với OS kernel".
runc
runc là bản hiện thực tham chiếu (reference implementation) của OCI container-runtime-spec — Docker, Inc. tham gia rất sâu vào việc định nghĩa chuẩn này lẫn phát triển chính runc. runc rất nhỏ gọn — về bản chất là 1 CLI nhẹ bọc quanh libcontainer, chỉ có đúng 1 nhiệm vụ: tạo container — và làm việc đó rất tốt, rất nhanh. runc thường được gọi là 1 "container runtime".
containerd
Để dùng được runc, Docker Engine cần 1 thành phần đóng vai trò cầu nối giữa daemon và runc — đó chính là containerd. containerd hiện thực lại toàn bộ execution logic vốn từng nằm trong daemon (đã được refactor và tối ưu lại khi viết thành containerd). Dễ hình dung containerd như 1 "container supervisor" — chịu trách nhiệm các thao tác vòng đời container: start/stop, pause/un-pause, và destroy container. Cũng như runc, containerd nhỏ gọn, nhẹ, và chỉ tập trung đúng 1 việc — quản lý vòng đời container. containerd do Docker, Inc. phát triển rồi hiến tặng (donate) cho Cloud Native Computing Foundation (CNCF).
Ví dụ: chuyện gì xảy ra khi chạy docker container run?
$ docker container run --name ctr1 -it alpine:latest sh
/ #
(Output minh họa — prompt / # là dấu hiệu đang đứng trong shell sh của container ctr1 vừa tạo, tương tự ví dụ /bin/bash ở Phần 1.)
- Khi gõ lệnh này vào Docker CLI, Docker client chuyển nó thành đúng API payload và POST tới đúng API endpoint.
- API được hiện thực ngay trong daemon — chính bộ REST API phong phú, có version, đã trở thành chuẩn de facto của ngành container.
- Nhận được lệnh tạo container mới, daemon gọi sang containerd (nhớ rằng daemon không còn chứa code tạo container nữa!) — giao tiếp qua 1 API kiểu CRUD trên gRPC.
- Dù tên gọi là vậy, containerd không tự tạo được container — nó dùng runc để làm việc đó: chuyển Docker image cần dùng thành 1 OCI bundle (1 thư mục chứa sẵn rootfs của image đã giải nén, cộng 1 file
config.jsonmô tả cách chạy nó — runc chỉ cần đọc đúng 2 thứ này, không cần biết gì về khái niệm "Docker image"), rồi bảo runc dùng bundle đó tạo container mới. - runc giao tiếp trực tiếp với OS kernel để dựng đủ mọi thành phần cần thiết tạo nên 1 container (trên Linux gồm namespace và cgroup). Tiến trình container được khởi chạy như 1 child-process của runc, và ngay khi tiến trình đó khởi động xong, runc thoát ra (exit).
Container giờ đã chạy!
Lợi ích lớn của mô hình này: "daemonless containers"
Vì toàn bộ logic tạo/quản lý container đã tách khỏi daemon, container runtime giờ hoàn toàn decoupled khỏi Docker daemon — hiện tượng này thường được gọi là "daemonless containers". Nhờ vậy có thể bảo trì/nâng cấp Docker daemon mà không ảnh hưởng tới container đang chạy.
Ở mô hình cũ (toàn bộ logic runtime nằm trong daemon), mỗi lần start/stop daemon sẽ kill hết container đang chạy trên host — vấn đề lớn trong môi trường production, đặc biệt khi Docker liên tục ra bản mới. Với mô hình mới, vấn đề này không còn nữa.
Vai trò của shim
Khi containerd tạo container, nó fork 1 instance runc riêng cho mỗi container. Nhưng ngay sau khi container được tạo xong, tiến trình runc cha đó thoát ra — nhờ vậy có thể chạy hàng trăm container mà không cần duy trì hàng trăm instance runc cùng lúc. Khi tiến trình runc cha thoát, tiến trình containerd-shim liên kết sẽ trở thành parent process mới của container. Một số trách nhiệm của shim với vai trò "cha" của container:
- Giữ các luồng STDIN/STDOUT luôn mở, để khi daemon restart, container không bị terminate do pipe bị đóng.
- Báo cáo exit status của container ngược về daemon.
Trên hệ thống Linux, các thành phần trên được hiện thực dưới dạng các binary riêng biệt. Ở giai đoạn đầu (khi sách gốc viết), chúng còn mang tiền tố docker- (docker-containerd, docker-containerd-shim, docker-runc) và được đóng gói chung với daemon; từ Docker 18.09, containerd và runc đã tách hẳn thành package riêng, không còn tiền tố docker-, còn shim đổi tên thành containerd-shim-runc-v2. Thử tự kiểm chứng bằng cách lọc đúng các process này trên Docker host:
$ ps -ef | grep -E 'dockerd|containerd|runc'
# Output minh họa
root 1204 1 0 09:12 ? 00:00:02 /usr/bin/dockerd -H fd://
root 1231 1 0 09:12 ? 00:00:01 /usr/bin/containerd
root 1522 1231 0 09:14 ? 00:00:00 containerd-shim-runc-v2 -namespace moby -id 6dc20d508db0 -address /run/containerd/containerd.sock
(Output minh họa — cấu trúc và tên process đúng theo Docker hiện tại, số liệu PID chỉ mang tính ví dụ.)
Để ý là runc không xuất hiện trong danh sách trên dù container vẫn đang chạy bình thường — đúng như vừa nói ở trên: runc chỉ tồn tại trong khoảnh khắc tạo container rồi thoát ngay, containerd-shim-runc-v2 mới là tiến trình cha sống lâu dài của container.
Vậy daemon còn lại những gì?
Với toàn bộ code execution/runtime đã bị lột ra khỏi daemon, phần còn lại trong daemon (tại thời điểm sách gốc viết) gồm: quản lý image, build image, REST API, authentication, security, core networking, và orchestration. Con số này sẽ tiếp tục thay đổi khi ngày càng nhiều chức năng được tách nhỏ và module hóa hơn nữa — quá trình module hóa Docker Engine vẫn đang tiếp diễn.
Theo kinh nghiệm vận hành thực tế, cái đáng nhớ nhất trong toàn bộ kiến trúc này không phải tên gọi từng thành phần, mà là hệ quả "daemonless containers" đã nói ở trên: dockerd giờ chỉ còn là 1 lớp API mỏng phía trên containerd, nên upgrade hay restart daemon không còn là nỗi lo mất container đang chạy như thời daemon nguyên khối. Hiểu rõ ai đang thực sự giữ container sống (containerd-shim, không phải dockerd hay runc) giúp bớt hoang mang hẳn khi debug — ví dụ như khi thấy dockerd restart mà container vẫn chạy bình thường, hay khi cần biết chính xác process nào cần kill nếu muốn "giết" container ở tầng OS thay vì qua docker container stop.
containerd và runc vừa dựng xong 1 container — nhưng dựng từ đúng "nguyên liệu" nào? Phần tiếp theo mổ xẻ Docker image: nó gồm những gì, vì sao lại xếp thành nhiều layer, và vì sao tag không phải lúc nào cũng đáng tin.
Nguồn tham khảo
Nguồn gốc: Bài viết lấy cảm hứng từ sách "Docker Deep Dive" — tác giả Nigel Poulton (bản phát hành version 4, 3/10/2017), Part 1: The Big Picture Stuff (chapter 4 "The big picture") và Part 2: The Technical Stuff (chapter 5 "The Docker Engine"). Nội dung được viết lại theo cách hiểu và ví dụ riêng, không sao chép nguyên văn hay dịch sát nguyên bản. Muốn đọc bản đầy đủ hoặc ủng hộ tác giả, có thể mua sách tại leanpub.com/dockerdeepdive.
Đính chính/cập nhật (fact-check):
- Tên binary
docker-containerd/docker-containerd-shim/docker-runc(tiền tốdocker-) chỉ đúng ở giai đoạn Docker 1.11–18.06; từ Docker 18.09 (11/2018),containerdvàrunctách thành package riêng (không còn tiền tốdocker-), shim đổi tên thànhcontainerd-shim-runc-v2— 11/2018, Docker Engine 18.09 release notes - OCI Image Format Spec và Runtime Spec cùng đạt bản 1.0.0 — 19/7/2017, Linux Foundation press release
- containerd được CNCF chấp nhận làm incubating project — 29/3/2017, CNCF announcement; graduated (mức trưởng thành cao nhất) — 28/2/2019, CNCF announcement