Kiến Trúc Docker: Client, Daemon, containerd và runc
Ở Bài 0 ta đã biết Docker đóng gói ứng dụng vào container. Bài này đi sâu hơn một bậc: khi bạn gõ docker run, thực ra có những thành phần nào tham gia và chúng phối hợp ra sao.
Nhiều người dùng Docker hằng ngày mà vẫn xem nó như một hộp đen — gõ lệnh, container chạy, hết. Hiểu kiến trúc bên trong không phải để khoe chữ, mà vì nó trả lời được những câu hỏi rất thực tế: vì sao restart Docker mà container vẫn sống? Vì sao trên máy Mac thao tác file lại chậm? Lỗi "Cannot connect to the Docker daemon" nghĩa là gì? Cuối bài bạn sẽ tự trả lời được, và còn tự tay soi được từng lớp bằng lệnh.
Docker là một hệ client–server
Điều đầu tiên cần nắm, và cũng là điều hay bị hiểu nhầm nhất: lệnh docker bạn gõ ở terminal không phải là thứ chạy container. Nó chỉ là một client gửi yêu cầu đi nơi khác.
Docker hoạt động theo mô hình client–server với hai phần riêng biệt:
- Docker client (
docker): công cụ dòng lệnh bạn gõ. Nhiệm vụ duy nhất của nó là nhận lệnh, đóng gói thành một yêu cầu, và gửi đi. Bản thân client không biết tạo container. - Docker daemon (
dockerd): một tiến trình chạy nền liên tục trên máy. Đây mới là nơi làm việc thật. Theo tài liệu Docker, daemon "lắng nghe các yêu cầu Docker API và quản lý các Docker object như image, container, network và volume".
Hai phần này nói chuyện qua một REST API. Đường truyền mặc định trên Linux là một UNIX socket tại /var/run/docker.sock; ngoài ra có thể qua mạng (TCP). Vì giao tiếp qua API tiêu chuẩn, client và daemon không bắt buộc nằm cùng một máy — bạn hoàn toàn có thể để client trên laptop điều khiển một daemon chạy trên server từ xa.
┌──────────────────────────────────────────────────────┐
│ Docker host (máy chạy daemon) │
│ │
│ ┌──────────┐ REST API ┌───────────────┐ │
│ │ docker │ ─────────────────► │ dockerd │ │
│ │ (client) │ qua UNIX socket │ (daemon) │ │
│ └──────────┘ /var/run/docker.sock └─────────────┘ │
│ ▲ │ │
│ └────────── kết quả ◄──────────────┘ │
└──────────────────────────────────────────────────────┘
Hệ quả thực tế đầu tiên: lỗi kinh điển "Cannot connect to the Docker daemon at unix:///var/run/docker.sock" gần như không bao giờ là do bạn gõ sai câu lệnh. Nó nghĩa là client gửi yêu cầu nhưng không ai trả lời ở đầu socket — tức dockerd chưa chạy, hoặc user của bạn không có quyền đọc/ghi socket đó (trên Linux thường cần thuộc nhóm docker).
Bên dưới daemon: containerd và runc
Đến đây một câu hỏi tự nhiên: vậy dockerd tự tạo container à? Không hẳn. dockerd quản lý ở mức cao (API, network, volume, build image), nhưng việc thực sự dựng và chạy container thì nó ủy thác xuống hai lớp bên dưới, mỗi lớp một nhiệm vụ tách bạch:
- containerd: quản lý vòng đời container ở mức trung gian. Nó lo kéo image từ registry, quản lý lưu trữ image trên đĩa, rồi chuẩn bị và giám sát việc chạy container.
dockerdra lệnh cho containerd qua một API riêng (gRPC). - runc: công cụ thực sự tạo ra container, và chỉ làm đúng việc đó. Nó là một runtime tuân theo chuẩn OCI (Open Container Initiative). runc nhận một bản cấu hình, dùng các tính năng của nhân Linux — namespaces và cgroups (ta mổ xẻ ở Bài 2) — để dựng môi trường cô lập, khởi động tiến trình bên trong, rồi thoát ngay sau khi tạo xong. runc không ở lại trông container.
Vậy ai trông container sau khi runc thoát? Đó là một tiến trình nhỏ gọi là shim (containerd-shim). Với mỗi container, containerd sinh ra một shim làm "cha" của tiến trình bên trong container. Shim ở lại suốt vòng đời container.
dockerd ── API, build, network, volume (mức cao)
│ gRPC
▼
containerd ── kéo image, quản lý lưu trữ, vòng đời
│
▼
containerd-shim ── ở lại trông container
│ gọi
▼
runc ── dựng namespaces + cgroups rồi THOÁT (OCI)
│
▼
[ tiến trình trong container, vd: nginx ]
Chính cái shim này trả lời câu hỏi: vì sao restart Docker daemon mà container đang chạy không bị giết? Vì tiến trình trong container không phải là con của dockerd — nó là con của shim. dockerd (và cả containerd) có thể dừng, nâng cấp, khởi động lại, mà shim cùng container vẫn sống. Khi daemon chạy lại, nó kết nối lại với các shim đang có.
Vì sao phải tách thành nhiều lớp thay vì gộp hết vào dockerd? Vì mỗi lớp tuân theo chuẩn chung (OCI cho runtime), nên có thể thay thế độc lập — ví dụ đổi runc sang một runtime khác cùng chuẩn. Việc tách containerd và runc ra khỏi Docker còn cho phép các hệ khác dùng lại chúng: Kubernetes chẳng hạn, gọi thẳng containerd mà không cần Docker. Nói cách khác, Docker đã "tháo rời" phần lõi chạy container thành các mảnh tái sử dụng được.
Điều gì xảy ra khi bạn gõ docker run nginx
Ghép tất cả lại, đây là chuỗi sự kiện đầy đủ khi bạn chạy một container:
$ docker run nginx
│
(1) ▼ client dịch lệnh thành yêu cầu API, gửi qua socket
docker ───────────────────────────────────► dockerd
│
(2) image "nginx" có sẵn trên máy chưa? ─── chưa ─┤
▼
(3) containerd ──pull──► Docker Hub
│ tải các layer, ghép thành
│ filesystem cho container
▼
(4) runc ──► tạo namespaces + cgroups,
│ gắn filesystem, chạy nginx
▼
(5) containerd-shim ──► trông tiến trình nginx
│
(6) kết quả ◄───────── dockerd ◄─────────┘
│
▼
container đang chạy
- Client nhận
docker run nginx, dịch thành yêu cầu API và gửi tớidockerd. - dockerd kiểm tra image
nginxđã có trên máy chưa. - Nếu chưa, nó nhờ containerd kéo image về từ registry (mặc định Docker Hub), tải đủ các layer rồi ghép thành một hệ thống file cho container.
- containerd gọi runc để tạo container: runc dựng namespaces và cgroups, gắn hệ thống file, khởi động tiến trình nginx.
- runc thoát; shim ở lại trông tiến trình.
- Container chạy.
dockerdtrả kết quả ngược về client.
Lần sau gõ docker ps, hãy nhớ thông tin đó cũng đi đúng chuỗi client → API → daemon này, chỉ là theo chiều đọc thay vì tạo.
Registry: nơi image đến và đi
Ở bước (3), image được kéo từ một registry — kho chứa image. Mặc định Docker dùng Docker Hub, nhưng nó có thể là registry riêng của công ty, hoặc dịch vụ như Amazon ECR, GitHub Container Registry.
Quan hệ rất đơn giản và đối xứng: bạn pull (kéo) image từ registry về máy để chạy, và push (đẩy) image mình dựng được lên registry để máy khác lấy về. Bài 4 sẽ đi kỹ vào image, layer và registry.
Vì sao Docker trên macOS và Windows lại có thêm một máy ảo
Container Linux cần một nhân Linux để chạy, vì runc dùng namespaces và cgroups vốn là tính năng của nhân Linux. Trên máy Linux thì sẵn có nhân đó. Nhưng macOS và Windows không chạy nhân Linux.
Vì vậy Docker Desktop trên macOS/Windows âm thầm dựng một máy ảo Linux nhẹ ở bên dưới, và dockerd thực ra chạy bên trong VM đó. Client docker trên máy bạn nói chuyện với daemon nằm trong VM.
macOS / Windows (máy của bạn)
┌────────────────────────────────────────────┐
│ docker (client) │
│ │ │
│ │ REST API │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ VM Linux nhẹ (Docker Desktop dựng) │ │
│ │ dockerd → containerd → runc │ │
│ │ [ các container chạy ở đây ] │ │
│ └──────────────────────────────────────┘ │
└────────────────────────────────────────────┘
Hiểu điều này giải thích vài hành vi hay làm người mới bối rối:
- "Ổ đĩa" mà container nhìn thấy là của VM Linux, không phải trực tiếp ổ đĩa máy bạn.
- Khi bạn mount một thư mục từ máy host vào container (bind mount, Bài 6), file phải đi qua một lớp chia sẻ giữa host và VM — nên truy cập file trên macOS/Windows thường chậm hơn rõ rệt so với chạy thẳng trên Linux.
- Tài nguyên container dùng (CPU, RAM) bị giới hạn bởi phần bạn cấp cho VM trong cài đặt Docker Desktop.
Tự tay soi từng lớp
Lý thuyết là vậy, giờ kiểm chứng trên máy bạn (cần đã cài Docker — nếu chưa, Bài 3 sẽ hướng dẫn, bạn có thể quay lại mục này sau).
Xem cả phiên bản client lẫn server (daemon) — chính sự tách biệt client/server thể hiện ngay ở đây:
docker version
Kết quả chia làm hai khối rõ rệt, Client: và Server:. Khối Server còn liệt kê phiên bản của containerd và runc — đúng các lớp ta vừa nói.
Xem thông tin chi tiết của daemon:
docker info
Để ý các dòng như Server Version, Storage Driver (thường là overlay2 — Bài 4 sẽ nói), và phần containerd version / runc version. Đây là các lớp bên dưới đang thực sự chạy.
Trên một máy Linux, bạn có thể nhìn thấy các tiến trình của từng lớp:
ps -ef | grep -E "dockerd|containerd|runc" | grep -v grep
Bạn sẽ thấy dockerd và containerd chạy nền. Khi có container đang chạy, sẽ có thêm tiến trình shim cho mỗi container. (Trên macOS/Windows, các tiến trình này nằm trong VM Linux nên không hiện ở đây.)
Tổng kết
Docker không phải một khối liền. Nó là một client gửi lệnh qua REST API tới daemon dockerd; daemon ủy thác việc chạy container xuống containerd rồi runc, với một shim giữ cho mỗi container sống độc lập khỏi daemon. Thiết kế nhiều lớp theo chuẩn OCI này là lý do cả hệ sinh thái container — kể cả Kubernetes — dùng chung được phần lõi.
Nắm kiến trúc này, ba câu hỏi đầu bài đã có lời đáp: restart daemon không giết container là nhờ shim; file chậm trên Mac là vì lớp VM ở giữa; lỗi "cannot connect" là daemon chưa chạy hoặc thiếu quyền socket.
Bài 2 ta xuống lớp thấp nhất: chính xác thì namespaces, cgroups và union filesystem — ba thứ runc dựa vào — biến một tiến trình Linux bình thường thành một container như thế nào.