Container 3 — Docker Compose, mạng & volume
Một container thì hiếm khi đủ
Ở bài Container là gì chúng ta đã hiểu container là một tiến trình bị cô lập, và ở bài Dockerfile & image chúng ta đã đóng gói ứng dụng thành image. Nhưng một ứng dụng thực tế hiếm khi chỉ là một container. Một hệ thống web tối thiểu thường gồm ít nhất ba mảnh: một web (frontend hoặc reverse proxy), một api (backend xử lý logic), và một db (cơ sở dữ liệu). Ba mảnh này phải:
- Khởi động theo đúng thứ tự (db sẵn sàng trước khi api kết nối).
- Tìm thấy nhau để gọi qua lại (api phải biết db ở đâu).
- Chia sẻ cấu hình (mật khẩu, tên database) mà không hardcode.
- Giữ được dữ liệu của db kể cả khi container bị xoá và tạo lại.
Chạy tay từng lệnh docker run cho cả ba, nhớ đúng cờ --network, -v, -e, -p theo đúng thứ tự, mỗi lần deploy lại gõ lại — đó là con đường dẫn tới lỗi. Bài này giới thiệu bốn công cụ giải quyết đúng bốn vấn đề trên: Docker Compose (khai báo cả stack trong một file), mạng container (cho container tìm và gọi nhau), volume (giữ dữ liệu bền), và registry (chia sẻ image giữa các máy).
Quy ước: các ví dụ trong bài dùng PostgreSQL và dịch vụ nhiều container. Môi trường viết bài không chạy được các stack này, nên mọi khối lệnh đều ghi (minh hoạ) — bạn hãy chạy trên máy có Docker.
Docker Compose: khai báo cả stack trong một file
Docker Compose là công cụ đọc một file YAML (mặc định compose.yaml, tên cũ docker-compose.yml) mô tả toàn bộ các service của ứng dụng, rồi dựng tất cả bằng một lệnh. Thay vì "gõ lệnh để tạo container", bạn mô tả trạng thái mong muốn rồi để Compose lo. Đây là bước đầu tiên chuyển từ tư duy mệnh lệnh (imperative) sang tư duy khai báo (declarative) — cùng triết lý mà Kubernetes đẩy lên quy mô cụm.
Một file Compose gồm các mục chính:
services: danh sách các dịch vụ (mỗi service là một loại container).volumes: các named volume dùng chung.networks: các mạng nội bộ.
Mỗi service có thể khai báo các khoá phổ biến sau:
image: dùng image có sẵn (ví dụpostgres:16), hoặcbuild: chỉ thư mục chứa Dockerfile để Compose tự build image.ports: publish cổng ra máy host theo dạng"HOST:CONTAINER".environment/env_file: truyền biến môi trường.volumes: gắn volume hoặc bind mount vào container.depends_on: khai báo thứ tự và điều kiện khởi động.healthcheck: cách Docker kiểm tra service đã "khoẻ" chưa.networks: gắn service vào (các) mạng nào.
Vòng đời cơ bản chỉ có hai lệnh:
# (minh hoạ) dựng toàn bộ stack, -d = chạy nền
docker compose up -d
# (minh hoạ) xem trạng thái và log
docker compose ps
docker compose logs -f api
# (minh hoạ) dừng và xoá container + network (volume giữ lại)
docker compose down
Compose phù hợp nhất cho môi trường dev (dựng nguyên stack trên máy lập trình viên trong vài giây) và cho stack nhỏ chạy trên một máy. Nó không phải công cụ cho production nhiều máy — phần cuối bài sẽ nói vì sao.
Mạng container: cho các container tìm thấy nhau
Các network driver
Docker cung cấp vài loại mạng (driver):
bridge(mặc định): Docker tạo một mạng ảo riêng trên host; mỗi container được cấp một IP nội bộ trong mạng đó. Container trong cùng bridge nói chuyện được với nhau; ra ngoài internet thì đi qua NAT. Đây là loại dùng cho hầu hết trường hợp.host: container dùng chung network stack với host, không có lớp cô lập mạng — nhanh hơn chút nhưng mất cách ly và dễ đụng cổng.none: container không có mạng, hoàn toàn cô lập — dùng cho các job xử lý offline không cần kết nối.
DNS nội bộ: gọi nhau bằng TÊN SERVICE
Đây là điểm quan trọng nhất của phần mạng. Khi bạn tạo một user-defined bridge network (Compose tự tạo một mạng như vậy cho mỗi project), Docker chạy một DNS nội bộ. Nhờ đó, một container gọi container khác bằng tên service, không cần biết IP.
Ví dụ, nếu api và db cùng nằm trong một network do Compose tạo, thì trong code của api, chuỗi kết nối database chỉ cần trỏ tới hostname db:
postgresql://appuser:secret@db:5432/appdb
Docker DNS sẽ phân giải db thành IP hiện tại của container db. Điều này cực kỳ quan trọng vì IP của container thay đổi mỗi lần tạo lại — nếu hardcode IP thì hệ thống sẽ vỡ ngay khi restart. Tên service là địa chỉ ổn định. (Đây chính là ý tưởng "service discovery" mà Services trong K8s nâng lên tầm cụm nhiều máy.)
Lưu ý: DNS theo tên service chỉ hoạt động trên user-defined network, không hoạt động trên mạng bridge mặc định cũ (default bridge chỉ hỗ trợ liên kết qua IP). Vì Compose luôn tạo user-defined network riêng cho project, bạn được lợi này miễn phí.
Publish port: host so với nội bộ
Cần phân biệt rõ hai loại "cổng":
- Cổng nội bộ giữa các container: không cần
ports. api gọidb:5432được ngay chỉ nhờ hai container cùng network. Không nên publish cổng db ra host. - Cổng publish ra host (
ports: "8080:80"): mở cổng 8080 trên máy host, chuyển vào cổng 80 của container. Chỉ dùng cho những service cần truy cập từ bên ngoài (thường là web/reverse proxy). Cú pháp làHOST:CONTAINER.
Nguyên tắc: chỉ publish những gì buộc phải public. Database nên chỉ nằm trong mạng nội bộ, không có dòng ports nào — như vậy nó không thể bị truy cập trực tiếp từ ngoài host.
Mạng riêng để cô lập
Bạn có thể tạo nhiều network và gắn service vào các mạng khác nhau để cô lập tầng. Ví dụ:
- Mạng
frontend: chứa web và api. - Mạng
backend: chứa api và db.
Khi đó web không cùng mạng với db, nên web không thể kết nối trực tiếp tới db kể cả khi biết tên — mọi truy cập dữ liệu buộc phải đi qua api. Đây là cách áp dụng nguyên tắc đặc quyền tối thiểu (least privilege) ở tầng mạng.
Volume: giữ dữ liệu khi container biến mất
Lớp ghi của container là ephemeral (tạm thời): khi container bị xoá, mọi thứ ghi vào filesystem của nó cũng mất theo. Với database, đó là thảm hoạ. Giải pháp là tách dữ liệu ra khỏi vòng đời container bằng volume. Có hai kiểu chính:
- Named volume: Docker quản lý một vùng lưu trữ có tên (ví dụ
db-data), nằm trong khu vực Docker quản lý trên host. Bạn không cần quan tâm đường dẫn thật. Đây là lựa chọn ưu tiên cho dữ liệu do container sinh ra và cần bền — điển hình là dữ liệu database. Named volume tồn tại độc lập,docker compose downkhông xoá nó (phải thêm cờ-vmới xoá). - Bind mount: gắn một thư mục cụ thể trên host vào container (ví dụ
./src:/app). Dùng cho dev khi muốn sửa code trên host và container thấy ngay, hoặc để đưa file cấu hình vào. Nhược điểm: phụ thuộc đường dẫn và layout của máy host, kém di động.
Quy tắc chọn:
| Nhu cầu | Dùng |
|---|---|
| Dữ liệu DB, cần bền, do container ghi | Named volume |
| Sync code lúc dev (hot reload) | Bind mount |
| File config đọc-only đưa vào container | Bind mount (read-only) |
| Dữ liệu tạm không cần giữ | Không cần volume |
Với database, hãy luôn gắn một named volume vào thư mục dữ liệu của DB (với PostgreSQL là /var/lib/postgresql/data). Nhờ đó bạn có thể nâng cấp image, xoá và tạo lại container mà dữ liệu vẫn còn nguyên.
Sơ đồ stack
api gắn vào cả hai mạng nên vừa nhận request từ web (mạng frontend) vừa nói chuyện với db (mạng backend); db chỉ ở backend nên web không chạm tới được; dữ liệu db nằm ở named volume db-data nên không mất khi container tái tạo.
Ví dụ Compose đa dịch vụ
Dưới đây là một stack hoàn chỉnh gồm api + db, với volume và hai mạng cô lập. (minh hoạ) — không chạy trong môi trường viết bài.
# compose.yaml (minh hoạ)
services:
api:
build: ./api # build từ Dockerfile trong ./api
ports:
- "8080:3000" # publish: host 8080 -> container 3000
environment:
# trỏ tới db BẰNG TÊN SERVICE, không dùng IP
DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@db:5432/appdb
env_file:
- .env # nạp thêm biến từ file .env (không commit)
depends_on:
db:
condition: service_healthy # chỉ khởi động khi db đã "khoẻ"
networks:
- frontend
- backend
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data # named volume -> dữ liệu bền
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- backend # db KHÔNG có ports -> không lộ ra host
restart: unless-stopped
volumes:
db-data: # Docker quản lý, sống độc lập với container
networks:
frontend:
backend:
Đọc kỹ ví dụ này, ta thấy đủ các nguyên tắc: api trỏ tới db bằng tên service; db không publish cổng nên chỉ truy cập được qua mạng nội bộ; mật khẩu lấy từ biến ${DB_PASSWORD} chứ không viết thẳng; db có named volume để giữ dữ liệu; và depends_on ... condition: service_healthy kết hợp với healthcheck bảo đảm api chỉ chạy khi db thật sự sẵn sàng nhận kết nối.
Env & secret: cấu hình mà không hardcode
Ba cơ chế truyền cấu hình vào container:
environment: liệt kê biến ngay trong file Compose. Tiện cho biến không nhạy cảm (tên DB, chế độ chạy).env_file: nạp biến từ một fileVAR=VAL. Dùng để tách cấu hình theo môi trường (dev/staging).- File
.env: Compose tự đọc file.envở cùng thư mục để thay thế biến trong chính file Compose (ví dụ${DB_PASSWORD}ở trên). Đây là cách giữ giá trị nhạy cảm ra ngoài file Compose.
Cảnh báo về secret: biến môi trường không phải cơ chế bảo mật mạnh. Giá trị environment nằm ngay trong file Compose (thường được commit) và lộ ra khi ai đó chạy docker inspect. Nguyên tắc tối thiểu:
- Không bao giờ viết mật khẩu/khoá trực tiếp trong
compose.yaml. - Đặt secret trong
.envvà thêm.envvào.gitignoređể không lọt lên Git. - Với production, dùng cơ chế secret chuyên dụng (Docker secrets, hoặc Secret của K8s / vault) thay vì env thường.
profiles cho phép bật/tắt nhóm service tuỳ ngữ cảnh: gán profiles: ["debug"] cho một service, nó chỉ chạy khi bạn kích hoạt profile đó (ví dụ docker compose --profile debug up). Hữu ích cho các công cụ phụ (adminer, mailhog) chỉ cần lúc dev.
Registry: chia sẻ image giữa các máy
Compose có thể build image tại chỗ, nhưng để chạy trên máy khác (server, CI, cụm K8s) thì image phải được đẩy lên một registry — kho chứa image dùng chung.
Vòng đời cơ bản:
# (minh hoạ) gắn tag theo dạng <registry>/<repo>:<tag>
docker tag my-api:latest registry.example.com/team/my-api:1.4.0
# (minh hoạ) đẩy lên registry
docker push registry.example.com/team/my-api:1.4.0
# (minh hoạ) máy khác kéo về
docker pull registry.example.com/team/my-api:1.4.0
Có hai loại registry: công khai như Docker Hub (nơi bạn kéo postgres, nginx...), và private registry (nội bộ công ty, cloud như ECR/GCR/ACR, hoặc self-hosted). Với dữ liệu và image nội bộ nên dùng private để kiểm soát.
Ý nghĩa với CI/CD: mỗi lần code merge, pipeline build image, gắn tag (thường theo commit SHA hoặc số phiên bản), push lên registry, rồi môi trường triển khai pull đúng tag đó. Tag bất biến (immutable) giúp bạn biết chính xác phiên bản nào đang chạy và roll back được. Tránh chỉ dựa vào latest cho production vì nó là "mục tiêu di động", không cho biết đang chạy gì.
Từ Compose lên Kubernetes: khác tư duy, không chỉ khác quy mô
Compose và Kubernetes cùng giải bài toán "điều phối nhiều container", nhưng ở hai tầng khác nhau:
| Docker Compose | Kubernetes | |
|---|---|---|
| Phạm vi | Một máy | Cụm nhiều máy |
| Tự phục hồi khi máy chết | Không | Có (dời sang máy khác) |
| Scale ra nhiều host | Không | Có |
| Cân bằng tải nhiều bản sao | Hạn chế | Có (Service) |
| Độ phức tạp | Thấp, học nhanh | Cao |
| Hợp cho | Dev, stack nhỏ 1 máy | Production, HA |
Điểm mấu chốt: Compose hợp với một máy. Khi bạn cần nhiều máy để chịu tải hoặc để đạt tính sẵn sàng cao (HA) — máy này chết vẫn còn máy khác — thì Compose không giải quyết được, vì nó không có khái niệm cụm. Lúc đó chuyển sang Kubernetes.
Đây không chỉ là "phiên bản lớn hơn của Compose" mà là khác tư duy: bạn không còn khai báo "chạy container X trên máy này", mà khai báo "tôi muốn có 3 bản sao service X ở đâu đó trong cụm", rồi K8s tự chọn máy, tự khởi động lại, tự cân bằng tải. Những khái niệm như mạng nội bộ theo tên và volume mà ta học ở đây vẫn còn — nhưng được K8s hiện thực lại ở tầng cụm (Service cho DNS, PersistentVolume cho lưu trữ).
Use case thực tế
Bối cảnh: một nhóm nhỏ vận hành cổng nội bộ tra cứu dữ liệu, gồm frontend, một API và PostgreSQL, chạy trên một server. Với Compose, họ làm như sau:
- Viết
compose.yamlba service với hai mạng (frontend,backend) để db chỉ nằm ở backend, không lộ cổng ra host. - Gắn named volume
db-datacho PostgreSQL — nhờ đó khi nâng cấp từpostgres:15lênpostgres:16(đổi container), dữ liệu vẫn còn. - Đặt mật khẩu DB trong
.env(đã ignore), tham chiếu bằng${DB_PASSWORD}— không secret nào bị commit. - Thêm
healthcheckbằngpg_isreadycho db vàdepends_on: condition: service_healthycho api, để api không crash vì kết nối lúc db chưa mở cổng. - CI build image API, push lên private registry theo tag commit; trên server chỉ cần
docker compose pull && docker compose up -d.
Sáu tháng sau, lượng người dùng tăng và ban lãnh đạo yêu cầu hệ thống "không được sập". Một server không đủ HA — họ bắt đầu chuyển stack này sang Kubernetes, dùng lại đúng các khái niệm mạng/volume đã quen. Compose vẫn được giữ cho môi trường dev trên máy lập trình viên.
Ghi nhớ
- Docker Compose mô tả cả stack đa dịch vụ trong một file YAML;
up/downdựng và gỡ toàn bộ. Hợp cho dev và stack nhỏ một máy. - Container trong cùng một user-defined network gọi nhau bằng TÊN SERVICE qua DNS nội bộ — không bao giờ hardcode IP (IP đổi khi tạo lại).
- Chỉ publish (
ports) những service cần truy cập từ ngoài; database nên chỉ nằm trong mạng nội bộ, không cóports. - Tách mạng (frontend/backend) để cô lập tầng theo nguyên tắc đặc quyền tối thiểu.
- Lớp ghi container là ephemeral → dùng named volume cho dữ liệu bền (nhất là DB), bind mount cho dev/hot-reload.
- Không hardcode secret: dùng
.env(đã gitignore) và biến${...}; env không phải bảo mật mạnh — production dùng secret chuyên dụng. healthcheck+depends_on: condition: service_healthyđể khởi động đúng thứ tự và tránh crash lúc phụ thuộc chưa sẵn sàng.- Registry (Docker Hub / private) để chia sẻ image; tag bất biến theo phiên bản là nền tảng của CI/CD, tránh phụ thuộc
latest. - Cần nhiều máy / HA thì Compose hết đất dùng → chuyển sang Kubernetes; đó là khác tư duy, và các khái niệm mạng/volume ở đây được K8s hiện thực lại ở tầng cụm (Services K8s).
Bài viết liên quan
Continuous Integration/Delivery/Deployment, cấu trúc pipeline, ví dụ GitHub Actions và chiến lược release.
Container vs máy ảo, image/layer, Dockerfile, volume, network, Docker Compose và best practices.
Vì sao cần orchestration; Pod, Deployment, Service, Ingress; scaling, self-healing và cấu hình.
Bản chất DevOps: phá bỏ rào cản Dev–Ops, vòng lặp vô tận, CALMS, và vì sao tự động hoá.