docker runBạn gõ một dòng, và mười hai việc xảy ra sau lưng bạn
Năm giây sau khi nhấn Enter, trình duyệt hiện trang chào của nginx. Cảm giác là xong rồi. Tờ này đi ngược lại dòng lệnh đó — kéo một gói tin qua sáu chặng, hạ sáu tấm kính namespace, và đối chiếu số hiệu thật của container với số hiệu của máy chủ.
Một dòng lệnh, mười hai việc bạn không nhìn thấy
Bạn gõ đúng một dòng, đợi khoảng năm giây, mở trình duyệt và thấy trang chào của nginx. Cảm giác là "xong rồi". Thực tế là mười hai việc vừa xảy ra và bạn mới nhìn thấy đúng một trong số đó.
$ docker run -d --name web -p 8080:80 nginx:1.29-alpine Unable to find image 'nginx:1.29-alpine' locally 1.29-alpine: Pulling from library/nginx 612c0c1df4c5: Pull complete 583599bb7d38: Pull complete aee4e54b3865: Pull complete 82736a35d0e7: Pull complete 4a8b0b2a5b19: Pull complete 781ff50d2644: Pull complete 453da7dbc73e: Pull complete 6a0ac1617861: Pull complete e8fc446e336c: Download complete 6192e1e6a438: Download complete Digest: sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de Status: Downloaded newer image for nginx:1.29-alpine 9718e1e039ec8b1837ff4df1ac32b126ab814a2cd990ce2eec229deccf7d16e9
Chuỗi 64 ký tự cuối cùng là ID đầy đủ của container. Mọi lệnh sau này bạn chỉ cần 12 ký tự đầu, hoặc cái tên web. Tờ này đi ngược từ dòng lệnh đó, mổ từng mảnh một.
Bốn mảnh của một lệnh, bốn hệ quả khác nhau
Lệnh trên có bốn phần tuỳ chọn và một phần bắt buộc. Mỗi phần đụng vào một tầng khác nhau của hệ thống — và đây là lý do người mới hay sửa nhầm chỗ khi gặp lỗi.
Mảnh docker. Gọi chương trình client. Nó chưa làm gì với container cả. Tầng bị ảnh hưởng: CLI.
Điểm dễ bỏ qua nhất là -d. Nó không có nghĩa "chạy nền cho gọn". Nó quyết định terminal của bạn có bị gắn vào tiến trình chính hay không — và kéo theo chuyện điều gì xảy ra khi bạn nhấn Ctrl+C.
| Mảnh | Đụng vào | Bỏ đi thì |
|---|---|---|
-d | luồng vào ra của tiến trình | terminal bị gắn vào nginx, Ctrl+C dừng container |
--name web | sổ tên của daemon | Docker tự đặt tên kiểu serene_khorana |
-p 8080:80 | bảng chuyển tiếp gói tin của nhân | container chạy nhưng không ai ngoài máy chủ chạm tới được |
nginx:1.29-alpine | kho image | không có mặc định — đây là phần bắt buộc |
Số nhà ngoài đường và số căn hộ bên trong
Cổng container là số căn hộ trong một toà chung cư
Toà chung cư hai mươi tầng. Bên trong, các căn đánh số theo hệ riêng của toà: A-0801, A-0802. Ngoài đường thì cả toà chỉ có một số nhà. Người giao hàng tới số nhà đó, đọc bảng chỉ dẫn ở sảnh, mới biết leo lên căn nào. Không có dòng chỉ dẫn ấy thì họ đứng ngoài — dù trong căn vẫn có người ở nhà.
| Ở toà chung cư | Trên máy bạn |
|---|---|
| Số nhà ngoài đường | Cổng 8080 trên máy chủ |
| Số căn hộ bên trong | Cổng 80 bên trong container |
| Dòng dán trên bảng chỉ dẫn ở sảnh | Tham số -p 8080:80 |
| Hộp thư riêng của căn hộ | Network namespace riêng, IP 172.17.0.2 |
| Danh sách người ở riêng từng căn | PID namespace riêng — nginx là tiến trình số 1 |
| Tủ đồ riêng, không nhìn được sang nhà bên | Mount namespace riêng |
| Móng, khung nhà, hệ điện nước dùng chung | Nhân Linux của máy chủ |
Ba chỗ, và chỗ thứ nhất là chỗ mất tiền thật.
Một — bảng chỉ dẫn ở sảnh có bảo vệ đứng cạnh. -p 8080:80 thì không. Nó ghi thẳng một luật chuyển tiếp vào bảng NAT của nhân, và luật đó nằm trước chỗ ufw kiểm. Bạn bật ufw deny 8080 mà cổng vẫn mở cho cả Internet. Mặc định -p 8080:80 là mở ra 0.0.0.0, không phải mở cho riêng máy bạn.
Hai — căn hộ khoá cửa đi vắng vẫn là căn hộ, đồ đạc vẫn nằm đó, người vẫn về được. Container stop thì không còn ai bên trong — tiến trình bị giết thật, chỉ còn lớp ghi nằm trên đĩa. Không có trạng thái "container đang ngủ", chỉ có "có tiến trình" hoặc "không có tiến trình".
Ba — ở chung cư bạn sơn tường thì tường giữ màu đó. Trong container, mọi thứ bạn sửa bằng docker exec nằm ở lớp ghi và bay sạch khi tạo lại container. Ẩn dụ nhà ở đánh lừa mạnh nhất đúng ở điểm này: nó gợi cảm giác thường trú, còn container thì được thiết kế để vứt đi.
Request đi qua năm chặng trước khi tới nginx
Mở http://localhost:8080 trên trình duyệt. Gói tin không đi thẳng vào nginx. Nó qua năm chặng, và biết năm chặng đó là biết chỗ để soi khi hỏng.
Chặng 1 trên sáu: trình duyệt. Trình duyệt mở kết nối TCP tới cổng 8080 của chính máy này. Chưa có gì liên quan tới Docker.
-p 8080:80 mặc định mở ra 0.0.0.0. Trên một VPS, dòng đó nghĩa là cả Internet gõ được vào cổng 8080 — và ufw deny 8080 không cứu được, vì luật của Docker nằm trước chỗ ufw kiểm.Hai chặng đầu nằm hoàn toàn trên máy chủ và không liên quan gì tới Docker. Chặng thứ ba là chỗ Docker chen vào: một luật DNAT viết bởi daemon lúc container khởi động.
$ docker port web 80/tcp -> 0.0.0.0:8080 80/tcp -> [::]:8080 $ ss -ltn | grep 8080 LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* LISTEN 0 4096 [::]:8080 [::]:*
0.0.0.0 nghĩa là mọi địa chỉ của máy này, gồm cả IP công cộng. Trên một VPS, dòng đó có nghĩa là cả Internet gõ được vào cổng 8080. Đọc kỹ dòng này là thói quen đáng rèn.
Muốn chỉ mình bạn vào được thì viết rõ địa chỉ: -p 127.0.0.1:8080:80. Khi đó ss in ra 127.0.0.1:8080 và không còn dòng [::]. Đây nên là mặc định trong đầu bạn, còn 0.0.0.0 là ngoại lệ có chủ ý.
$ docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{$v.IPAddress}}{{end}}' web bridge 172.17.0.2 $ docker exec web ip addr show eth0 2: eth0@if275: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP link/ether 66:a9:0a:46:eb:40 brd ff:ff:ff:ff:ff:ff inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
Ký hiệu eth0@if275 là dấu vết của một cặp giao diện ảo: eth0 bên trong container nối với giao diện số 275 bên ngoài, cắm vào cầu docker0. Cầu đó chính là cổng 172.17.0.1 — địa chỉ mà nginx ghi vào log khi bạn gọi từ máy chủ.
$ docker logs web | tail -1 172.17.0.1 - - [23/Aug/2026:02:04:37 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/8.5.0" "-"
Nginx không thấy 127.0.0.1, cũng không thấy IP thật của bạn. Nó thấy cổng của cầu.
Một giây hai trăm mili giây đó gồm những gì
Từ lúc bạn nhấn Enter (image đã có sẵn trên máy) tới lúc nginx nhận request đầu tiên là khoảng hơn một giây. Bên trong khoảng đó có tám mốc rõ ràng.
Mốc 1 trên 8: dựng lời gọi API. CLI đổi lệnh của bạn thành POST /v1.52/containers/create, kèm toàn bộ tham số ở dạng JSON.
Mốc đáng chú ý nhất là mốc thứ sáu: runc gọi clone() với các cờ tạo namespace, rồi exec nginx, rồi tự thoát. Sau đó không còn runc nào trên máy — thứ ở lại là shim, đúng như Tờ 02 đã mổ xẻ.
$ docker ps CONTAINER ID IMAGE STATUS PORTS 9718e1e039ec nginx:1.29-alpine Up 2 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp $ docker stats --no-stream web CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % PIDS 9718e1e039ec web 0.00% 6.082MiB / 11.68GiB 0.05% 7
Container nhìn thấy gì, và không nhìn thấy gì
Cách ly của container không phải phép màu. Nó là sáu cơ chế của nhân Linux gọi là namespace, và bạn đếm được chúng bằng số hiệu.
Sáu ô kính đang đóng.
time và user trùng khít. Container dùng chung đồng hồ hệ thống và chung bảng định danh người dùng với máy chủ. $ docker exec web ls -l /proc/1/ns cgroup -> cgroup:[4026532677] ipc -> ipc:[4026532675] mnt -> mnt:[4026532673] net -> net:[4026532678] pid -> pid:[4026532676] time -> time:[4026531834] user -> user:[4026531837] uts -> uts:[4026532674] $ readlink /proc/self/ns/{time,user} time:[4026531834] user:[4026531837]
user trùng nghĩa là root bên trong container chính là root của máy chủ, chỉ bị giới hạn bằng capability và seccomp. Đây là lý do gắn -v /:/host rồi chroot là con đường leo quyền, và là lý do chế độ rootless tồn tại. Docker mặc định không bật user namespace riêng.
$ docker exec web ps -ef | head -3 PID USER TIME COMMAND 1 root 0:00 nginx: master process nginx -g daemon off; 30 nginx 0:00 nginx: worker process
Bên trong, nginx là tiến trình số 1. Bên ngoài, cùng tiến trình đó mang một số PID lớn của máy chủ. Cùng một tiến trình, hai cái tên, tuỳ bạn đứng ở đâu mà nhìn.
Mọi thứ bạn sửa đều nằm ở một chỗ
Image là chỉ đọc. Mọi thay đổi trong lúc container chạy rơi vào một lớp ghi riêng nằm trên cùng, và docker diff cho bạn xem đúng lớp đó.
$ docker exec web sh -c 'echo "ghi thu" > /tmp/ghichu.txt' $ docker diff web C /var/cache/nginx A /var/cache/nginx/scgi_temp A /var/cache/nginx/client_temp A /var/cache/nginx/proxy_temp C /etc/nginx/conf.d/default.conf A /run/nginx.pid C /tmp A /tmp/ghichu.txt
A là thêm mới, C là đã đổi, D là đã xoá. Nhìn danh sách này là biết ngay: chính nginx cũng ghi vào lớp đó ngay lúc khởi động — nó sửa default.conf để bật IPv6 và tạo mấy thư mục đệm.
$ docker stop web web $ docker ps -a CONTAINER ID STATUS PORTS NAMES 9718e1e039ec Exited (0) 1 second ago (trống) web $ docker start web web $ docker exec web cat /tmp/ghichu.txt ghi thu
Chú ý cột PORTS trống khi container đã dừng. Luật chuyển tiếp cổng chỉ tồn tại cùng tiến trình; dừng container là cổng 8080 trên máy chủ được trả lại ngay lập tức.
"Em mở localhost:8080 mà không lên"
Đây là câu hỏi phổ biến nhất trong tuần đầu học Docker, và nó có bốn nguyên nhân hoàn toàn khác nhau. Chẩn đoán phải đi từ ngoài vào trong, không được đoán mò.
Bước 1: docker ps -a — container có đang Up không?
docker ps -a rồi chọn theo cái bạn thật sự nhìn thấy. Bấm vào ô câu hỏi để mở output của cả hai trường hợp.Nhánh khó thấy nhất là nhánh thứ ba: container chạy, cổng đã công bố, nhưng công bố nhầm số cổng bên trong. Triệu chứng rất dễ đánh lừa vì docker ps trông vẫn khoẻ mạnh.
$ docker run -d --name web -p 8080:8081 nginx:1.29-alpine a917880484a3958c0a07f60ae08af512f2758f00e30357e73… $ docker ps CONTAINER ID STATUS PORTS a917880484a3 Up 1 second 80/tcp, 0.0.0.0:8080->8081/tcp, [::]:8080->8081/tcp $ curl -sS http://localhost:8080 curl: (56) Recv failure: Connection reset by peer
Đọc cột PORTS cho ra toàn bộ câu chuyện: 80/tcp đứng riêng nghĩa là image có khai cổng 80 nhưng không ai công bố nó; còn 0.0.0.0:8080->8081/tcp là chuyển tiếp tới một cổng mà bên trong không có ai nghe. Sửa bằng cách đổi thành -p 8080:80.
Năm sai lầm tốn thời gian nhất khi chạy container đầu tiên
Dùng -d với một lệnh kết thúc ngay rồi tưởng container "chết bất thường"
docker run -d --name thu alpine:3.22 echo "toi chay xong roi" cho ra Exited (0) ngay. Container không hỏng — nó làm xong đúng việc được giao rồi thoát với mã 0.
Cách sửaMuốn nó chạy lâu thì lệnh chính phải là thứ chạy lâu. Và luôn dùng docker ps -a chứ không phải docker ps khi thấy container "biến mất".
Chép docker inspect -f '{{.NetworkSettings.IPAddress}}' từ hướng dẫn cũ
Trên Docker 29, lệnh đó trả về template parsing error: … map has no entry for key "IPAddress". Trường ở tầng gốc đã bị bỏ.
Cách sửadocker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{$v.IPAddress}}{{end}}' web
Tưởng -p 8080:80 chỉ mở cổng cho máy mình
Mặc định là mở ra 0.0.0.0 — mọi địa chỉ của máy, gồm cả IP công cộng. Nặng hơn: luật chuyển tiếp của Docker nằm trước chỗ ufw kiểm, nên ufw deny 8080 không cứu được.
Cách sửaTrên máy chủ hãy mặc định viết -p 127.0.0.1:8080:80, rồi cho một proxy phía trước lo phần ra Internet. Kiểm lại bằng ss -ltn | grep 8080.
Dùng localhost bên trong container để gọi container khác
Bên trong container, localhost là chính nó — vì nó có network namespace riêng. Ứng dụng gọi localhost:5432 để tìm Postgres đang chạy ở container bên cạnh sẽ không bao giờ tới nơi.
Cách sửaCho hai container vào cùng một mạng do bạn tạo, rồi gọi nhau bằng tên container. Mạng bridge mặc định không có phân giải tên; mạng bạn tự tạo thì có.
Sửa cấu hình bằng docker exec rồi coi như xong
Vào container sửa default.conf, thấy chạy đúng, đóng terminal. Lần tạo lại container tiếp theo, mọi thứ trở về như cũ — docker diff sẽ cho thấy chính xác những gì bạn sắp mất.
Cách sửaThay đổi phải nằm trong image, trong volume, hoặc trong tham số lúc chạy. docker exec là để quan sát, không phải để sửa.
Kiểm tra hiểu bài
Năm câu, không giới hạn thời gian. Mỗi câu giải thích cả đáp án đúng lẫn vì sao các đáp án kia sai.
docker run -d --name thu alpine:3.22 echo "toi chay xong roi". Ngay sau đó docker ps không thấy gì, còn docker ps -a hiện Exited (0). Chuyện gì đã xảy ra?Chọn 1docker run -d -p 8080:80 nginx:1.29-alpine trên một VPS có IP công cộng.Chọn nhiềuroot bên trong container là một danh tính khác hẳn với root của máy chủ.Đúng / Saidocker ps hiện cột PORTS là 80/tcp, 0.0.0.0:8080->8081/tcp, container đang Up, nhưng curl http://localhost:8080 trả về Recv failure: Connection reset by peer. Nguyên nhân là gì?Chọn 1api gọi localhost:5432 để tìm Postgres đang chạy trong container db trên cùng một máy. Kết nối không bao giờ tới nơi. Chọn tất cả khẳng định đúng.Chọn nhiềuNăm điều mang về
Container sống đúng bằng vòng đời tiến trình chính. Không có "container ngủ". Có tiến trình thì chạy, hết tiến trình thì Exited.
Cổng đọc từ ngoài vào trong. -p <cổng máy chủ>:<cổng container>, và mặc định là mở ra toàn Internet.
Cách ly là sáu namespace, không phải phép màu. Sáu cái riêng, hai cái dùng chung — time và user. Cái thứ hai là chuyện bảo mật.
Mọi thay đổi lúc chạy nằm ở lớp ghi. docker diff cho xem toàn bộ, và lớp đó mất cùng container.
Gỡ rối đi từ ngoài vào trong. ss → docker ps cột PORTS → docker logs → docker exec. Bốn lệnh, đúng thứ tự, phủ gần hết mọi trường hợp.