Skip to main content

5. Docker Containers

Mục lục


Phần 4 dừng lại ở image — thứ nằm yên, chỉ-đọc, chờ được dùng. Phần này bước sang phía "sống" của Docker: container — thực thể thực sự chạy, thực sự dùng CPU/RAM, thực sự có thể bị dừng hay xóa.

1. Container là gì?

Container là thực thể (instance) đang chạy của 1 image — cũng giống như khởi chạy 1 VM từ 1 VM template, ta khởi chạy 1 hoặc nhiều container từ cùng 1 image. Khác biệt lớn nhất giữa VM và container: container nhanh và nhẹ hơn hẳn — thay vì chạy nguyên 1 OS đầy đủ như VM, container dùng chung OS/kernel với host đang chạy nó.

💡 Hình dung: image giống 1 khuôn bánh (đứng yên, không đổi), container giống từng cái bánh đúc ra từ khuôn đó (đang "sống", có thể ăn, có thể vỡ, mỗi cái độc lập với nhau) — đúc bao nhiêu cái bánh cũng được từ cùng 1 khuôn, và làm vỡ 1 cái bánh không ảnh hưởng gì tới cái khuôn hay những cái bánh khác.

Image (đứng yên) → nhiều Container (đang chạy)Docker Imageđứng yên, không đổiContainer APID 1: tiến trình đang chạyContainer BPID 1: tiến trình đang chạydocker container runXóa/dừng 1 container không hề ảnh hưởng tới image gốc hay container khác

Cách đơn giản nhất để khởi chạy container là docker container run. Ở dạng cơ bản nhất, chỉ cần cho nó biết dùng image nào và chạy lệnh gì:

docker container run <image> <command>

Ví dụ chạy 1 container Ubuntu với Bash shell: docker container run -it ubuntu /bin/bash. Cờ -it gắn (attach) terminal hiện tại vào shell của container.

Container chạy cho tới khi tiến trình nó đang thực thi kết thúc. Ví dụ dễ thấy nhất: bảo container chạy lệnh sleep 10 — container khởi động, chạy 10 giây rồi tự thoát:

$ docker container run alpine:latest sleep 10
$ docker container ls -a
# Output minh họa (sau khi lệnh trên chạy xong)
CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES
7fcd3311c19a alpine:latest "sleep 10" 11 seconds ago Exited (0) 1 second ago silly_ride

(Output minh họa — sleep 10 không in gì ra màn hình, chỉ tự thoát sau đúng 10 giây; STATUS: Exited (0) là bằng chứng container đã tự kết thúc sạch sẽ, không phải bị kill.)

Dừng thủ công bằng docker container stop, khởi động lại bằng docker container start, và xóa vĩnh viễn bằng docker container rm.


2. Container vs VM — 2 mô hình ảo hóa khác nhau

Phần 1 đã dùng SVG so sánh VM và container ở tầm tổng quan (mỗi VM cần 1 OS riêng — "thuế OS"/"VM tax" — trong khi mọi container trên 1 host dùng chung đúng 1 OS). Container và VM đều cần 1 host để chạy, nhưng khác nhau ở tầng bị ảo hóa: hypervisor thực hiện ảo hóa phần cứng — chia nhỏ tài nguyên phần cứng vật lý thành phiên bản ảo (VM); container thực hiện ảo hóa cấp OS — chia nhỏ tài nguyên OS (process tree, filesystem, network stack) thành phiên bản ảo, không đụng tới phần cứng.

Nhắc lại đúng phần cốt lõi cần nhớ khi làm việc với container: mỗi OS cài thêm (mỗi VM) kéo theo CPU/RAM/storage riêng, license riêng, việc patch/upgrade riêng, và 1 attack surface riêng (attack surface: tổng số điểm mà kẻ tấn công có thể nhắm vào để khai thác — càng nhiều OS/service chạy, càng nhiều điểm có thể bị khai thác). Container gộp tất cả về đúng 1 kernel duy nhất cho cả host, nên dù chạy hàng trăm container, vẫn chỉ có 1 hóa đơn "thuế OS", 1 attack surface ở tầng kernel.

Hệ quả dễ thấy nhất trong vận hành hàng ngày: container khởi động chưa tới 1 giây — không có kernel riêng nào cần định vị/giải nén/khởi tạo, vì kernel dùng chung ở tầng host vốn đã chạy sẵn từ trước. Thời gian khởi động container lúc này chỉ còn phụ thuộc vào chính ứng dụng bên trong nó, không phải của cả 1 OS.


3. Chạy và tương tác với container

Kiểm tra Docker daemon

$ docker version
# Output minh họa
Client: Docker Engine - Community
Version: 24.0.7
Server: Docker Engine - Community
Engine:
Version: 24.0.7

(Output minh họa — nếu cả 2 phần Client/Server đều hiện version thì daemon đang sống và nói chuyện được với client; số version cụ thể chỉ mang tính ví dụ.)

Nếu phần Server báo lỗi, trên Linux kiểm tra user đã thuộc group docker cục bộ chưa (usermod -aG docker <user>, rồi logout/login lại), hoặc kiểm tra daemon có đang chạy không:

# Linux không dùng Systemd
$ service docker status
# Output minh họa
docker start/running, process 1042

# Linux dùng Systemd
$ systemctl is-active docker
# Output minh họa
active

# Windows Server 2016 (PowerShell)
> Get-Service docker
# Output minh họa
Status Name DisplayName
------ ---- -----------
Running docker Docker Engine

(Output minh họa — cả 3 lệnh trên chỉ khác cú pháp, cùng mục đích xác nhận daemon đang chạy; số liệu cụ thể chỉ mang tính ví dụ.)

Khởi chạy container đơn giản

$ docker container run -it ubuntu:latest /bin/bash
Unable to find image 'ubuntu:latest' locally
latest: Pulling from library/ubuntu
...
root@3027eb644874:/#

Cú pháp chung: docker container run <options> <image>:<tag> <command>. Khi Enter, Docker client gọi API tương ứng tới daemon; daemon kiểm tra local cache — nếu chưa có image, nó tự pull về từ Docker Hub trước khi tạo container và chạy lệnh chỉ định bên trong.

Trên Linux mặc định, daemon expose Docker Remote API qua local IPC/Unix socket /var/run/docker.sock; trên Windows là qua named pipe npipe:////./pipe/docker_engine. Cũng có thể cấu hình client/daemon giao tiếp qua network (cổng mặc định không TLS là 2375, có TLS là 2376).

Shell prompt đổi thành root@<12-ký-tự-đầu-của-container-ID>:/# — bạn đang thực sự ở trong container. Lưu ý: image container thường được tối ưu tối đa nên không có đủ mọi command/package như 1 máy Linux thông thường (ví dụ ping thường không có sẵn trong image Ubuntu chính thức).

Tiến trình bên trong container

Phần 3 đã nói vì sao /bin/bash bên trong container luôn hiện ra là PID 1 (nhờ PID namespace riêng, không phải vì nó thực sự là tiến trình đầu tiên của cả host). Hệ quả trực tiếp của điều đó: container Ubuntu ở trên chỉ chạy đúng 1 tiến trình sống lâu dài: /bin/bash (PID 1). Vì container không thể tồn tại nếu không có tiến trình đang chạy — gõ exit để thoát Bash sẽ kill luôn cả container, vì kill tiến trình PID 1 nghĩa là kill luôn tiến trình duy nhất của nó (Windows container cũng theo đúng quy tắc này, dù thường chạy nhiều tiến trình hơn).

Muốn thoát mà không kill container, nhấn Ctrl-P Ctrl-Q — quay lại shell của host, container vẫn chạy nền. Xem danh sách container đang chạy bằng docker container ls.

Gắn lại (re-attach) vào container đang chạy bằng docker container exec — lệnh này tạo 1 tiến trình mới bên trong container rồi attach vào đó, nên gõ exit lần này không kill container (vì tiến trình gốc PID 1 vẫn đang chạy song song):

$ docker container exec -it <tên-hoặc-id-container> bash
# Output minh họa
root@3027eb644874:/#

(Output minh họa — prompt giống hệt lúc docker container run -it, nhưng lần này là 1 tiến trình bash hoàn toàn mới, không phải PID 1 gốc.)

docker run vs docker exec: khác nhau ở tiến trình nào bị killContainer đang chạyPID 1: bash (gốc)tạo lúc docker runPID 7: bash (mới)tạo lúc docker execdocker execexit ở PID 1 → ❌ kill luôn cả containerexit ở PID 7 → ✅ chỉ đóng shell mới, container vẫn sống

4. Vòng đời container

Một hiểu lầm phổ biến: container không lưu được dữ liệu bền vững (persist). Sai — container hoàn toàn làm được, chỉ là nó cũng rất giỏi ở việc chạy non-persistent nên hay bị đánh đồng.

Theo dõi vòng đời 1 container từ sinh ra, làm việc, "nghỉ phép", tới khi bị xóa hẳn — đặt tên nó là ben cho dễ nhớ (gợi từ "bền", vì sắp chứng minh nó bền dữ liệu):

$ docker container run --name ben -it ubuntu:latest /bin/bash
root@9cb2d2fd1d65:/#

Ghi dữ liệu vào container:

root@9cb2d2fd1d65:/# cd tmp
root@9cb2d2fd1d65:/tmp# echo "hello persistent world" > newfile
root@9cb2d2fd1d65:/tmp# cat newfile
hello persistent world

Thoát bằng Ctrl-P Ctrl-Q, rồi dừng container (cho nó "đi nghỉ"):

$ docker container stop ben
# Output minh họa
ben

(Output minh họa — lệnh chỉ echo lại đúng tên container vừa dừng thành công.)

docker container ls (không có -a) sẽ không còn thấy container này vì nó đã ở trạng thái dừng — thêm -a để thấy cả container đã dừng:

$ docker container ls -a
# Output minh họa
CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES
9cb2d2fd1d65 ubuntu:latest "/bin/bash" 4 minutes Exited (0) ben

(Output minh họa — cấu trúc và tên cột đúng theo lệnh thật (cột đầu là CONTAINER ID), ID/thời gian cụ thể chỉ mang tính ví dụ.)

Dừng container giống như tắt 1 VM — dù không chạy, toàn bộ cấu hình và nội dung vẫn còn nguyên trên filesystem của host, có thể khởi động lại bất cứ lúc nào bằng docker container start:

$ docker container start ben
# Output minh họa
ben

(Output minh họa — lệnh chỉ echo lại đúng tên container vừa khởi động lại thành công.)

Attach lại và kiểm tra file đã tạo trước đó vẫn còn nguyên — chứng minh dừng container không hủy container hay dữ liệu bên trong nó. (Dù ví dụ này minh họa tính bền vững của container, cách được khuyến nghị để lưu dữ liệu bền vững thực tế vẫn là dùng volume — data sống độc lập hẳn khỏi vòng đời container thay vì chỉ "sống sót nhờ chưa bị rm"; xem chi tiết ở Phần 6.)

Cuối cùng, xóa hẳn container theo 2 bước — dừng trước, xóa sau (best practice, để tiến trình bên trong có cơ hội dừng sạch sẽ):

$ docker container stop ben
$ docker container rm ben
# Output minh họa
ben
ben

(Output minh họa — mỗi lệnh echo lại đúng tên container, theo đúng thứ tự gọi.)

Vòng đời containerRunningStoppedstopstartRemovedrmContainer + data còn nguyênContainer + data vẫn còn trên hostContainer + data bị xóa hẳnChỉ có "rm" mới thực sự xóa — "stop" chỉ tạm ngưng, không đụng tới dữ liệu(dữ liệu lưu trong volume thì sống sót cả sau khi container bị "rm")

Tóm lại: có thể stop/start/pause/restart container bao nhiêu lần tùy ý, container và dữ liệu của nó luôn an toàn — chỉ khi chủ động kill/xóa container mới có nguy cơ mất dữ liệu (và nếu dữ liệu đã lưu trong volume, nó vẫn tồn tại kể cả sau khi container biến mất).


5. Dừng container "lịch sự"

Vì sao khuyến nghị dừng trước rồi mới xóa (2 bước), thay vì xóa thẳng bằng docker container rm <container> -f?

  • docker container rm <container> -f kill ngay lập tức, không báo trước — tiến trình bên trong container không có cơ hội dọn dẹp trước khi chết.
  • docker container stop lịch sự hơn hẳn — gửi tín hiệu Unix (cơ chế OS dùng để báo cho 1 tiến trình biết cần làm gì, không cần tiến trình đó chủ động kiểm tra) tên SIGTERM tới tiến trình PID 1 bên trong container, cho nó cơ hội tự dọn dẹp và tắt êm. Nếu không thoát trong vòng 10 giây, nó nhận tiếp SIGKILL — tín hiệu buộc dừng ngay lập tức, tiến trình không có cách nào "chống lại" hay bỏ qua tín hiệu này.

💡 Hình dung: SIGTERM giống gõ cửa lịch sự và nói "sắp đóng cửa rồi, thu dọn đi" — người bên trong có thời gian tắt bếp, khóa cửa sổ. SIGKILL giống cắt điện thẳng cả tòa nhà — bất kể đang làm gì cũng dừng ngay lập tức, không kịp dọn dẹp.

docker container rm <container> -f bỏ qua hẳn bước SIGTERM lịch sự, đi thẳng luôn tới SIGKILL.

docker container stop: SIGTERM → chờ tối đa 10s → SIGKILLdocker container stopSIGTERM → PID 1chờ tối đa 10s (grace period)Tiến trình tự thoát kịp✅ Stopped (clean exit)Sau 10s vẫn chưa thoátSIGKILL → Stopped (forced)

Trên Windows container, grace period mặc định trước khi gửi SIGKILL là 30 giây (thay vì 10 giây như Linux container) — có thể chỉnh cả hai bằng cờ --time của docker container stop.


6. Ví dụ web server & inspect container

Chạy 1 container web server đơn giản ở chế độ nền, map cổng 80 của host vào cổng 8080 bên trong container:

$ docker container run -d --name webserver -p 80:8080 myteam/hello-web
# Output minh họa
3e4f8a9b1c2d5e6f7890abcdef1234567890fedcba0987654321fedcba098765

(Output minh họa — cờ -d chạy nền nên output chỉ trả về ID đầy đủ của container vừa tạo, giống hệt cách docker run -d hoạt động đã thấy ở Part 3.)

  • -d (daemon mode): chạy nền, không attach vào terminal — loại trừ lẫn nhau với -it.
  • -p 80:8080: map port theo định dạng host-port:container-port — traffic vào host ở cổng 80 sẽ được chuyển tới cổng 8080 bên trong container.
Port mapping: -p 80:8080Docker Hosthost-port: 80traffic vào từ ngoàiContainer "webserver"container-port: 8080nơi app thật sự lắng nghe-p 80:8080Client bên ngoài chỉ cần biết cổng 80 của host — không cần biết gì về cổng 8080 bên trong container

Container này chạy web service dù lệnh docker container run không hề chỉ định chương trình cần chạy — vì khi build image, có thể embed sẵn 1 command mặc định cho container dùng image đó. Kiểm tra bằng docker image inspect, xem trường Cmd trong output — đây cũng là 1 dạng "tự tài liệu hóa" cho image: chỉ cần inspect là biết image được thiết kế để làm gì.

Bridge network: điều gì thực sự đứng sau -p?

-p 80:8080 ở trên hoạt động được là nhờ bridge network — network mặc định Docker tự tạo cho mọi container khi không chỉ định gì khác (khác với overlay network dùng trong swarm mode, sẽ nói ở Phần 8). Trên Linux, Docker tạo sẵn 1 virtual bridge tên docker0 ngay khi daemon khởi động; mỗi container mới sinh ra 1 cặp veth (virtual Ethernet) — 1 đầu cắm vào docker0, đầu còn lại trở thành eth0 bên trong container, được cấp 1 IP riêng trong dải mạng nội bộ (thường là 172.17.0.0/16).

💡 Hình dung: docker0 giống 1 switch mạng ảo cắm sẵn trong host — mỗi container là 1 máy tính cắm dây vào đúng switch đó, có địa chỉ IP riêng trong cùng 1 mạng LAN nội bộ. -p 80:8080 giống việc cấu hình NAT trên router nhà bạn: "ai gọi vào cổng 80 của router, chuyển thẳng vào đúng máy X ở cổng 8080" — máy ở ngoài internet không cần biết gì về địa chỉ LAN nội bộ của máy X.

Cụ thể, -p 80:8080 bảo Docker thêm 1 rule iptables (NAT/DNAT) trên host: gói tin tới cổng 80 của host được chuyển tiếp (forward) tới đúng IP nội bộ + cổng 8080 của container. Kiểm tra IP nội bộ của container và rule NAT tương ứng:

$ docker container inspect --format '{{.NetworkSettings.IPAddress}}' webserver
# Output minh họa
172.17.0.2

$ sudo iptables -t nat -L DOCKER -n
# Output minh họa (rút gọn)
Chain DOCKER (2 references)
target prot opt source destination
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:80 to:172.17.0.2:8080

(Output minh họa — IP nội bộ và rule cụ thể khác nhau tùy host, nhưng cấu trúc DNAT ... to:<container-ip>:<container-port> luôn đúng như vậy.)

Container khác trên cùng docker0 gọi thẳng nhau qua IP nội bộ (172.17.0.x) mà không cần qua -p — cờ -p chỉ cần khi muốn traffic từ bên ngoài host vào được container. Đây cũng là lý do 2 container trên cùng 1 host không cần overlay network hay VXLAN (Phần 8) gì cả — bridge network đã đủ, vì cả 2 đều nằm trên cùng 1 kernel, không cần "vượt" qua host khác.

Dọn dẹp nhanh toàn bộ container

$ docker container rm $(docker container ls -aq) -f
# Output minh họa
webserver
ben
happy_curie

(Output minh họa — mỗi dòng echo lại tên/ID 1 container đã bị xóa; docker container ls -aq trả về toàn bộ container ID kể cả đã dừng, -f bỏ qua bước dừng lịch sự (force).)

⚠️ Lưu ý: lệnh này hủy cưỡng bức toàn bộ container trên host, không cho chúng cơ hội dọn dẹp — tuyệt đối không chạy trên hệ thống production hoặc container quan trọng.


7. Giới hạn tài nguyên container (cgroup trong thực tế)

Phần 3 đã giải thích khái niệm cgroup (control group) — cơ chế kernel Linux dùng để giới hạn tài nguyên, tránh 1 container "tham lam" chiếm hết CPU/RAM của cả host. Vậy Docker có tự áp dụng giới hạn đó cho mọi container không? Không. Mặc định, docker container run không đặt giới hạn nào cả — 1 container hoàn toàn có thể dùng hết RAM hoặc CPU của host nếu ứng dụng bên trong nó có bug (memory leak, vòng lặp vô hạn...). Đây là nguồn gốc phổ biến của sự cố "1 container lỗi kéo sập cả host" trong thực tế vận hành.

Giới hạn RAM bằng cờ --memory (hoặc -m), giới hạn CPU bằng --cpus:

$ docker container run -d --name limited \
--memory 256m --cpus 0.5 \
nginx
# Output minh họa
3a8f9c1d2e4b5a6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c

(Output minh họa — --memory 256m giới hạn container dùng tối đa 256MB RAM, --cpus 0.5 giới hạn tối đa nửa lõi CPU; vượt giới hạn RAM sẽ bị kernel OOM-kill tiến trình bên trong container, không ảnh hưởng container khác.)

Xem lại giới hạn đã đặt cho 1 container đang chạy:

$ docker container inspect --format \
'Memory={{.HostConfig.Memory}} CPUs={{.HostConfig.NanoCpus}}' limited
# Output minh họa
Memory=268435456 CPUs=500000000

(Output minh họa — Docker lưu nội bộ dưới đơn vị byte (RAM) và nano-CPU (1 CPU = 1000000000), nên 256m hiện thành 268435456, 0.5 hiện thành 500000000.)

💡 Hình dung: không đặt --memory/--cpus giống thuê chung cư "không giới hạn điện nước" — tiện nhưng rủi ro là 1 phòng xài quá tay có thể khiến cả tòa nhà cúp điện. Đặt cgroup limit giống lắp đồng hồ điện riêng cho từng phòng: phòng nào xài vượt định mức tự bị cắt, các phòng khác không hề hay biết.

Theo dõi mức dùng thực tế theo thời gian thực (giống top, nhưng cho container) bằng docker container stats:

$ docker container stats limited --no-stream
# Output minh họa
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM %
3a8f9c1d2e4b limited 0.12% 4.398MiB / 256MiB 1.72%

(Output minh họa — cột MEM USAGE / LIMIT là bằng chứng trực tiếp cgroup limit đang có hiệu lực; --no-stream để lệnh in đúng 1 lần rồi thoát thay vì refresh liên tục.)

Trong production, nên đặt giới hạn tài nguyên cho mọi container quan trọng — không phải để tối ưu hiệu năng, mà để đảm bảo 1 container lỗi không kéo theo cả host hoặc container khác trên cùng máy.

Theo kinh nghiệm vận hành thực tế, phần lớn sự cố container liên quan tới đúng 3 điều đã nói trong phần này: quên đặt --memory/--cpus nên 1 container lỗi kéo sập cả host, dùng rm -f thay vì stop rồi rm nên mất dữ liệu chưa kịp flush, và nhầm lẫn giữa IP nội bộ bridge network với IP thật của host khi debug kết nối.

Biết chạy container từ image có sẵn là một chuyện — tự tay biến code của mình thành image lại là chuyện khác hẳn. Đó là phần tiếp theo: viết Dockerfile, build image, và (bù lại lời hứa còn treo ở trên) nói đúng về volume.


Danh sách lệnh chính

LệnhCông dụng
docker container run -it <image> <command>Khởi chạy container mới, chạy foreground và attach shell
docker container ls / ls -aLiệt kê container đang chạy (Up) / thêm cả container đã dừng (Exited)
docker container exec -it <container> <command>Chạy thêm 1 tiến trình mới bên trong container đang chạy, ví dụ mở thêm shell
docker container stop <container>Dừng container "lịch sự" (SIGTERM, sau 10s không thoát thì SIGKILL)
docker container start <container>Khởi động lại container đã dừng
docker container rm <container>Xóa container đã dừng (nên stop trước khi rm)
docker container inspect <container>Xem chi tiết cấu hình + runtime metadata của container
docker container run --memory <n> --cpus <n>Giới hạn RAM/CPU tối đa container được dùng (cgroup)
docker container stats [<container>]Xem mức dùng CPU/RAM thực tế theo thời gian thực
Ctrl-P Ctrl-QThoát khỏi shell container mà không kill nó

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 2: The Technical Stuff — chapter 7 "Containers". 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.

Đã verify qua research (Mode R) — các claim dưới đây vẫn đúng với Docker hiện tại, không cần đính chính:

  • docker container stop mặc định gửi SIGTERM rồi chờ 10 giây (Linux container) trước khi gửi SIGKILL; Windows container mặc định chờ 30 giây — Docker Docs — docker container stop
  • Cổng mặc định cho Docker Remote API qua network: 2375 (không TLS), 2376 (có TLS) — dockerd reference
  • Image Ubuntu chính thức không cài sẵn ping (cần gói iputils-ping) — chủ đích để tối giản kích thước và giảm attack surface, không phải thiếu sót