Skip to content

Docker Compose — Service Discovery, ENV & Healthchecks

🎓 Instructor Profile

Kỹ sư Raizo (Phó CTO HPN) — Chuyên gia kiến trúc microservices, người đã triển khai hàng chục cụm dịch vụ từ startup đến enterprise. Cùng Giáo sư Tom hướng dẫn bạn "nhạc trưởng hoá" quy trình phát triển với Docker Compose.

Bạn đã biết build image, viết Dockerfile chuẩn. Nhưng thực tế, không ứng dụng nào sống một mình. API cần Database, Database cần Cache, Worker cần Message Queue. Bài học này dạy bạn cách kết nối tất cả lại — chỉ với một file YAML duy nhất.


🎯 Mục tiêu

Sau bài học này, bạn sẽ:

  1. Hiểu tại sao cần Docker Compose và nó giải quyết vấn đề gì
  2. Viết được compose.yml hoàn chỉnh với nhiều services
  3. Nắm cơ chế Service Discovery — các container tìm nhau bằng tên, không bằng IP
  4. Quản lý Environment Variables an toàn (không leak secrets)
  5. Cấu hình depends_on + healthcheck để loại bỏ race condition khi khởi động
  6. Thành thạo bộ lệnh docker compose up/down/logs/exec

🧠 Phần 1: Concept — Tại sao cần Compose?

1.1 Nỗi đau thực tế: Nhiều container = Nhiều vấn đề

Hãy hình dung bạn đang xây dựng TaskFlow — một SaaS quản lý dự án. Stack công nghệ gồm:

ServiceVai tròImage
APIREST server xử lý business logicCustom (build từ Dockerfile)
PostgreSQLDatabase chính lưu trữ dữ liệupostgres:16-alpine
RedisCache session + job queueredis:7-alpine
WorkerXử lý tác vụ nền (gửi email, export)Custom (cùng codebase với API)

Không có Compose, bạn phải mở 4 tab Terminal và gõ từng lệnh:

bash
# Tab 1: Tạo network
docker network create taskflow-net

# Tab 2: Chạy PostgreSQL
docker run -d --name db --network taskflow-net \
  -e POSTGRES_USER=user -e POSTGRES_PASSWORD=pass \
  -e POSTGRES_DB=myapp \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16-alpine

# Tab 3: Chạy Redis
docker run -d --name redis --network taskflow-net \
  redis:7-alpine

# Tab 4: Chạy API (phải đợi DB sẵn sàng)
docker run -d --name api --network taskflow-net \
  -p 3000:3000 \
  -e DATABASE_URL=postgres://user:pass@db:5432/myapp \
  -e REDIS_URL=redis://redis:6379 \
  my-api:latest

# Tab 5: Chạy Worker
docker run -d --name worker --network taskflow-net \
  -e DATABASE_URL=postgres://user:pass@db:5432/myapp \
  -e REDIS_URL=redis://redis:6379 \
  my-api:latest npm run worker

5 lệnh dài dằng dặc. Sai một tham số là hệ thống "gãy". Quên tạo network? Container không tìm thấy nhau. Quên volume? Mất dữ liệu.

1.2 Compose = Khai báo hạ tầng bằng code (IaC)

Docker Compose giải quyết mọi thứ trên bằng một file YAML duy nhấtmột lệnh duy nhất:

bash
docker compose up -d

"Define and run multi-container Docker applications." — Tài liệu chính thức Docker

Compose là gì trong bức tranh lớn?

Công cụPhạm viDùng khi nào
DockerfileBuild một imageĐóng gói ứng dụng
Docker ComposeOrchestrate nhiều containers trên một máyDevelopment, testing, CI/CD
KubernetesOrchestrate nhiều containers trên nhiều máyProduction at scale

⚠️ Compose KHÔNG phải cho production orchestration

Compose tuyệt vời cho local developmenttesting. Nhưng nếu bạn cần auto-scaling, self-healing, rolling updates trên cluster — đó là việc của Kubernetes. Đừng nhầm lẫn phạm vi sử dụng.

1.3 Tư duy Architect: Tại sao SaaS Startup cần Compose?

Trong thực tế, team phát triển TaskFlow sử dụng Compose để:

  • Onboard developer mới trong 5 phút thay vì 2 ngày setup môi trường
  • Đảm bảo consistency — mọi người chạy cùng một stack, cùng phiên bản
  • Mirror production topology — local dev giống production (API + DB + Redis + Worker)
  • Chạy integration tests trong CI/CD pipeline trước khi merge code

Đó là lý do Compose là công cụ bắt buộc trong workflow của mọi team chuyên nghiệp.


📐 Phần 2: Syntax — Giải phẫu compose.yml

2.1 File mẫu thực tế: TaskFlow Stack

Đây là file compose.yml hoàn chỉnh cho hệ thống TaskFlow — đọc kỹ từng dòng comment:

yaml
# compose.yml — TaskFlow Development Stack
# Compose V2: KHÔNG cần "version:" nữa (deprecated từ Compose 2.x)

services:
  # ========================================
  # Service 1: API Server (Node.js)
  # ========================================
  api:
    build:
      context: .                    # Build context = thư mục hiện tại
      dockerfile: Dockerfile        # Dùng Dockerfile đã viết từ bài trước
    ports:
      - "3000:3000"                 # Expose port ra host để truy cập từ browser
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
      - REDIS_URL=redis://redis:6379
    depends_on:
      db:
        condition: service_healthy  # ⏳ Đợi DB THỰC SỰ sẵn sàng
      redis:
        condition: service_started  # Redis khởi động nhanh, không cần healthcheck
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s                 # Kiểm tra mỗi 10 giây
      timeout: 5s                   # Timeout mỗi lần kiểm tra
      retries: 3                    # Thử 3 lần trước khi báo unhealthy
      start_period: 15s             # Grace period cho app khởi động
    restart: unless-stopped

  # ========================================
  # Service 2: PostgreSQL Database
  # ========================================
  db:
    image: postgres:16-alpine       # Dùng image có sẵn, không cần build
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data   # 📦 Persist data qua restart
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s                  # DB cần kiểm tra thường xuyên hơn
      timeout: 3s
      retries: 5                    # Cho phép thử nhiều lần (DB khởi động chậm)
    restart: unless-stopped

  # ========================================
  # Service 3: Redis Cache
  # ========================================
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"                 # Expose cho debug tool (RedisInsight)
    restart: unless-stopped

# ========================================
# Khai báo Named Volumes
# ========================================
volumes:
  pgdata:                           # Docker quản lý volume này tự động

💡 Compose V2 — Không cần version: nữa

Từ Docker Compose V2, trường version: "3.8" đã bị deprecated. Compose tự phát hiện schema phù hợp. PENALGO dạy chuẩn V2:

  • V1 (Cũ): docker-compose up (có gạch nối) — Đã ngừng phát triển
  • V2 (Mới): docker compose up (dấu cách) — Dùng chuẩn này

2.2 Bảng tham chiếu nhanh: Các keyword quan trọng

KeywordMục đíchVí dụ
services:Định nghĩa danh sách dịch vụservices: api: ...
build:Build image từ Dockerfilebuild: { context: ., dockerfile: Dockerfile }
image:Dùng image có sẵn từ registryimage: postgres:16-alpine
ports:Map port Host:Containerports: ["3000:3000"]
environment:Đặt biến môi trườngNODE_ENV=development
volumes:Mount datapgdata:/var/lib/postgresql/data
depends_on:Thứ tự khởi động + điều kiệncondition: service_healthy
healthcheck:Kiểm tra sức khoẻ servicetest: ["CMD", "curl", ...]
restart:Chính sách restartunless-stopped, always, on-failure

🌐 Phần 3: Service Discovery — Các container tìm nhau như thế nào?

3.1 Vấn đề: IP thay đổi liên tục

Mỗi khi bạn chạy docker compose up, container nhận IP ngẫu nhiên từ Docker. Lần này db172.18.0.2, lần sau là 172.18.0.4. Nếu API hardcode IP để kết nối DB — chắc chắn gãy.

3.2 Giải pháp: DNS tự động bằng Service Name

Khi chạy docker compose up, Compose tự động:

  1. Tạo một Docker network riêng cho project (tên: <tên-thư-mục>_default)
  2. Gắn tất cả services vào network đó
  3. Tạo DNS entry cho mỗi service: tên service = hostname

Kết quả: API kết nối DB bằng hostname db, không bằng IP.

Connection String trong API:
postgres://user:pass@db:5432/myapp
                      ^^
                      Tên service, KHÔNG phải IP!

3.3 Sơ đồ Network Topology

┌─────────────────────────────────────────────────────────┐
│               Docker Network: taskflow_default          │
│                                                         │
│   ┌──────────┐     ┌──────────┐     ┌──────────┐       │
│   │   api    │     │    db    │     │  redis   │       │
│   │ :3000    │────▶│ :5432    │     │ :6379    │       │
│   │          │     │          │     │          │       │
│   │          │─────────────────────▶│          │       │
│   └────┬─────┘     └──────────┘     └────┬─────┘       │
│        │                                  │             │
│        │  DNS: "db" → 172.18.0.2          │             │
│        │  DNS: "redis" → 172.18.0.3       │             │
│        │                                  │             │
└────────┼──────────────────────────────────┼─────────────┘
         │                                  │
    ports: 3000:3000                   ports: 6379:6379
         │                                  │
         ▼                                  ▼
┌─────────────────────────────────────────────────────────┐
│                    HOST MACHINE                         │
│          localhost:3000     localhost:6379               │
└─────────────────────────────────────────────────────────┘

Quy tắc quan trọng:

Giao tiếpCần ports: không?Hostname
Service → Service (trong cùng network)KHÔNGDùng tên service: db:5432
Host → Container (từ browser/terminal)localhost:3000

💡 Quy tắc vàng

ports: chỉ cần khi bạn muốn truy cập service từ bên ngoài Docker network (browser, Postman, debug tool). Service-to-service communication KHÔNG cần ports:.

3.4 Chứng minh Service Discovery hoạt động

bash
# Từ container 'api', ping container 'db'
docker compose exec api ping db -c 3

# Kết quả:
# PING db (172.18.0.2): 56 data bytes
# 64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.089 ms
# 64 bytes from 172.18.0.2: seq=1 ttl=64 time=0.095 ms

# Từ container 'api', resolve DNS của 'redis'
docker compose exec api nslookup redis

# Kết quả:
# Name:   redis
# Address: 172.18.0.3

Service Discovery = tự động, không cần cấu hình gì thêm. Đó là sức mạnh của Compose.


🔐 Phần 4: Environment Handling — Quản lý biến môi trường

4.1 Ba cách đặt biến môi trường

Cách 1: Trực tiếp trong compose.yml (Hardcoded)

yaml
services:
  api:
    environment:
      - NODE_ENV=development          # Dạng list
      POSTGRES_USER: user             # Dạng mapping

Khi nào dùng: Chỉ cho giá trị không nhạy cảm (NODE_ENV, LOG_LEVEL, PORT).

Cách 2: Dùng file .env riêng biệt

Tạo file .env cùng thư mục với compose.yml:

ini
# .env — KHÔNG BAO GIỜ commit file này lên Git!
DB_USER=admin
DB_PASSWORD=SuperSecret123!@#
DB_NAME=taskflow
REDIS_PASSWORD=RedisSecret456
API_PORT=3000

Tham chiếu trong compose.yml bằng cú pháp ${VARIABLE}:

yaml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}

  api:
    ports:
      - "${API_PORT}:3000"
    environment:
      - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}

Cách 3: Dùng env_file: trỏ đến file riêng

yaml
services:
  api:
    env_file:
      - ./config/api.env            # Load tất cả biến từ file này
    environment:
      - NODE_ENV=development        # Override hoặc bổ sung

File config/api.env:

ini
DATABASE_URL=postgres://admin:secret@db:5432/taskflow
REDIS_URL=redis://redis:6379
JWT_SECRET=my-super-secret-key

4.2 Variable Substitution nâng cao

Compose hỗ trợ default values — cực hữu ích để tránh crash khi thiếu biến:

yaml
services:
  api:
    environment:
      # Nếu API_PORT chưa được set → dùng giá trị mặc định 3000
      - PORT=${API_PORT:-3000}

      # Nếu NODE_ENV chưa được set → dùng "development"
      - NODE_ENV=${NODE_ENV:-development}

    ports:
      # Port mapping cũng dùng được variable substitution
      - "${API_PORT:-3000}:3000"
Cú phápÝ nghĩa
${VAR}Lấy giá trị của VAR. Lỗi nếu không tồn tại
${VAR:-default}Lấy VAR, nếu không có → dùng default
${VAR:?error message}Lấy VAR, nếu không có → dừng và báo lỗi

4.3 Thứ tự ưu tiên (Precedence)

Khi cùng một biến được đặt ở nhiều nơi, Compose áp dụng thứ tự sau (cao → thấp):

  1. Shell environment (export trên terminal) — ưu tiên cao nhất
  2. .env file tại thư mục chứa compose.yml
  3. env_file: được khai báo trong compose.yml
  4. environment: trực tiếp trong compose.yml
  5. Dockerfile ENV — ưu tiên thấp nhất

⚠️ KHÔNG BAO GIỜ commit .env lên Git

File .env chứa mật khẩu, API keys, secrets. Commit lên Git = lộ thông tin toàn bộ hệ thống.

bash
# Thêm vào .gitignore NGAY LẬP TỨC
echo ".env" >> .gitignore
echo "*.env" >> .gitignore

# Tạo file mẫu để team biết cần những biến gì
cp .env .env.example
# Xóa giá trị nhạy cảm trong .env.example, chỉ giữ key

Tạo file .env.example (commit file này):

ini
# .env.example — Template cho team
DB_USER=
DB_PASSWORD=
DB_NAME=
REDIS_PASSWORD=
API_PORT=3000

🚦 Phần 5: depends_on + Healthchecks — Giải quyết Race Condition

5.1 Vấn đề: Container chạy ≠ Service sẵn sàng

Đây là kiến thức Deep Tech mà hầu hết Junior Developer bỏ qua — và trả giá bằng hàng giờ debug.

Kịch bản:

  1. Compose bật container db (PostgreSQL) lên trước
  2. PostgreSQL cần 5-15 giây để khởi tạo database
  3. Compose thấy container db đã running → bật container api lên ngay
  4. API cố kết nối db:5432"Connection Refused"Crash 💥
Timeline thảm hoạ:

   t=0s        t=1s         t=2s         t=12s
    │           │            │             │
    ▼           ▼            ▼             ▼
  [db start] [api start]  [api crash!]  [db ready]
              api kết nối    "Connection    Quá muộn!
              db:5432...     Refused"       API đã chết

5.2 depends_on thuần tuý — Chỉ là thứ tự, KHÔNG đợi sẵn sàng

yaml
# ❌ SAI — Lỗi phổ biến nhất
services:
  api:
    depends_on:
      - db                  # Chỉ đảm bảo container db KHỞI ĐỘNG trước
                            # KHÔNG đảm bảo PostgreSQL ĐÃ SẴN SÀNG nhận kết nối!

5.3 Giải pháp đúng: Healthcheck + condition: service_healthy

yaml
# ✅ ĐÚNG — Đợi service THỰC SỰ sẵn sàng
services:
  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]    # Lệnh kiểm tra sức khoẻ
      interval: 5s            # Kiểm tra mỗi 5 giây
      timeout: 3s             # Timeout mỗi lần check
      retries: 5              # Thử 5 lần trước khi báo unhealthy
      start_period: 10s       # Grace period ban đầu — không tính failure

  api:
    depends_on:
      db:
        condition: service_healthy   # 🟢 CHỈ KHỞI ĐỘNG KHI DB HEALTHY
Timeline đúng:

   t=0s        t=5s     t=10s       t=12s       t=13s
    │           │         │           │            │
    ▼           ▼         ▼           ▼            ▼
  [db start] [check 1] [check 2]  [db healthy]  [api start]
              pg_isready  pg_isready  pg_isready    Kết nối
              → FAIL      → FAIL      → OK ✅       db:5432 → OK ✅

5.4 Healthcheck cho các service phổ biến

ServiceHealthcheck commandGiải thích
PostgreSQLpg_isready -U userKiểm tra DB sẵn sàng nhận kết nối
MySQLmysqladmin ping -h localhostPing MySQL daemon
Redisredis-cli pingPhản hồi PONG nếu healthy
HTTP APIcurl -f http://localhost:3000/healthKiểm tra endpoint /health
MongoDBmongosh --eval "db.runCommand('ping')"Ping MongoDB

5.5 Bảng so sánh condition

ConditionHành viKhi nào dùng
service_startedĐợi container khởi động (mặc định)Service khởi động nhanh (Redis, Nginx)
service_healthyĐợi healthcheck báo healthyService cần thời gian init (DB, API phức tạp)
service_completed_successfullyĐợi container chạy xong và exit 0Migration script, seed data

🔬 Phần 6: Lab — Xây dựng TaskFlow Stack hoàn chỉnh

Bước 1: Tạo cấu trúc project

bash
mkdir taskflow && cd taskflow

# Tạo compose.yml (sẽ viết nội dung bên dưới)
touch compose.yml

# Tạo file .env
touch .env

# Tạo .gitignore
echo ".env" > .gitignore

Bước 2: Viết file .env

ini
# .env
DB_USER=taskflow_admin
DB_PASSWORD=Str0ngP@ssw0rd!
DB_NAME=taskflow_db
API_PORT=3000

Bước 3: Viết compose.yml hoàn chỉnh

yaml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "${API_PORT:-3000}:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
      - REDIS_URL=redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    restart: unless-stopped

volumes:
  pgdata:

Bước 4: Khởi động stack

bash
# Dựng toàn bộ stack và chạy nền (detached)
docker compose up -d

# Output kỳ vọng:
# [+] Running 4/4
#  ✔ Network taskflow_default  Created
#  ✔ Volume taskflow_pgdata    Created
#  ✔ Container taskflow-db-1   Healthy
#  ✔ Container taskflow-redis-1 Started
#  ✔ Container taskflow-api-1   Started

Bước 5: Kiểm tra trạng thái

bash
# Xem tất cả services và status
docker compose ps

# Output:
# NAME                STATUS              PORTS
# taskflow-api-1      Up (healthy)        0.0.0.0:3000->3000/tcp
# taskflow-db-1       Up (healthy)        5432/tcp
# taskflow-redis-1    Up                  0.0.0.0:6379->6379/tcp

Bước 6: Debug logs

bash
# Xem log của api service (theo dõi real-time)
docker compose logs -f api

# Xem log của tất cả services (50 dòng gần nhất)
docker compose logs --tail 50

# Xem log của db khi có vấn đề
docker compose logs db

Bước 7: Kiểm tra Service Discovery

bash
# Từ container api, ping container db
docker compose exec api ping db -c 3
# → PING db (172.18.0.2): 56 data bytes ...

# Kiểm tra DNS resolution
docker compose exec api nslookup redis
# → Name: redis  Address: 172.18.0.3

# Kiểm tra kết nối PostgreSQL từ api
docker compose exec api sh -c \
  "apt-get update > /dev/null && apt-get install -y postgresql-client > /dev/null && \
   pg_isready -h db -U \$POSTGRES_USER"
# → db:5432 - accepting connections

Bước 8: Dọn dẹp

bash
# Tắt stack, xóa containers và networks (GIỮ LẠI data)
docker compose down

# Tắt stack và XÓA SẠCH cả volumes (MẤT DATA!)
docker compose down -v

# Xóa images đã build
docker compose down --rmi local

🐛 Phần 7: Spot-the-Bug — Tìm lỗi trong compose.yml

Bài tập: Service api không kết nối được db

Developer nhận bug report: "API khởi động nhưng trả về 500 — Cannot connect to database". Hãy tìm tất cả lỗi trong file compose.yml sau:

yaml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://admin:secret@database:5432/myapp  # 🔍 Lỗi 1?
    depends_on:
      - db                                                        # 🔍 Lỗi 2?
    networks:
      - frontend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    networks:
      - backend

  redis:
    image: redis:7-alpine

networks:
  frontend:
  backend:
👉 Bấm để xem đáp án

Có 3 lỗi nghiêm trọng:

Lỗi 1: Sai hostname trong DATABASE_URL

DATABASE_URL=postgres://admin:secret@database:5432/myapp
                                     ^^^^^^^^
Sai! Service tên là "db" chứ không phải "database"
Phải sửa thành: @db:5432

Docker DNS chỉ resolve đúng tên service trong compose.yml. database không match service nào → DNS lookup fail → Connection Refused.

Lỗi 2: depends_on không có condition: service_healthy

yaml
depends_on:
  - db          # Chỉ đợi container start, KHÔNG đợi PostgreSQL sẵn sàng

Thiếu healthcheck cho db và thiếu condition: service_healthy. Kết quả: API khởi động trước khi DB ready → crash.

Lỗi 3: apidb nằm trên network khác nhau!

yaml
api:   networks: [frontend]     # Chỉ thuộc "frontend"
db:    networks: [backend]      # Chỉ thuộc "backend"

Hai service nằm trên hai networks tách biệtkhông thể giao tiếp. api phải thuộc cả frontend lẫn backend, hoặc cả hai phải dùng chung network.

File đã sửa:

yaml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://admin:secret@db:5432/myapp     # ✅ Đúng tên service
    depends_on:
      db:
        condition: service_healthy                              # ✅ Đợi DB healthy
    networks:
      - frontend
      - backend                                                 # ✅ Thuộc cả 2 networks

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    healthcheck:                                                # ✅ Thêm healthcheck
      test: ["CMD-SHELL", "pg_isready -U admin"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - backend

networks:
  frontend:
  backend:

🧩 Phần 8: Parsons Problem — Sắp xếp lại compose.yml

Các khối YAML dưới đây đã bị xáo trộn. Hãy sắp xếp lại thành file compose.yml hợp lệ để: web đợi db healthy trước khi khởi động.

yaml
# Khối A
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 3

# Khối B
services:

# Khối C
  web:
    image: nginx:alpine
    ports:
      - "80:80"

# Khối D
    depends_on:
      db:
        condition: service_healthy

# Khối E
  db:
    image: postgres:16-alpine
👉 Bấm để xem đáp án

Thứ tự đúng: B → E → A → C → D

yaml
services:             # B
  db:                 # E
    image: postgres:16-alpine
    healthcheck:      # A
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 3
  web:                # C
    image: nginx:alpine
    ports:
      - "80:80"
    depends_on:       # D
      db:
        condition: service_healthy

Logic: db phải được khai báo với healthcheck trước khi web có thể depends_on nó với condition: service_healthy.


🎯 Phần 9: Scenario — Xử lý tình huống thực tế

Tình huống: Team mới join startup, cần setup môi trường dev

Bạn vừa join team phát triển TaskFlow. Tech Lead giao việc:

"Setup local dev environment: API (Node.js) + PostgreSQL + Redis. Yêu cầu: một lệnh duy nhất để dựng toàn bộ stack. Phải có healthcheck cho DB. Secrets không được hardcode."

Thử thách: Viết file compose.yml.env hoàn chỉnh đáp ứng yêu cầu trên. Sau đó chạy:

bash
# Kiểm tra cú pháp (dry run)
docker compose config

# Dựng stack
docker compose up -d

# Verify: tất cả services healthy
docker compose ps

# Verify: service discovery hoạt động
docker compose exec api ping db -c 1

Tiêu chí đánh giá:

  • [ ] Tất cả secrets trong .env, không hardcode trong compose.yml
  • [ ] dbhealthcheck + api dùng condition: service_healthy
  • [ ] Variable substitution ${VAR:-default} cho giá trị không nhạy cảm
  • [ ] .gitignore chứa .env

⚠️ Pitfalls — Các bẫy thường gặp

⚠️ Cạm bẫy

Vấn đề: Dùng depends_on: [db] mà nghĩ rằng DB đã ready khi app khởi động.

Hậu quả: depends_on chỉ đảm bảo container được tạo trước. PostgreSQL cần 5-15 giây để initialize. App kết nối ngay → "Connection Refused" → crash loop. Trong CI/CD, test flaky vì race condition.

Giải pháp: Luôn kết hợp healthcheck + condition: service_healthy. Đây là bắt buộc cho mọi project.

yaml
# ❌ Race condition
depends_on:
  - db

# ✅ An toàn
depends_on:
  db:
    condition: service_healthy

⚠️ Cạm bẫy

Vấn đề: Service tên db nhưng connection string dùng @database:5432 hoặc @localhost:5432.

Hậu quả: DNS lookup fail → Connection timeout → App crash.

Giải pháp: Hostname trong connection string phải trùng với tên service trong compose.yml. localhost chỉ trỏ đến chính container đó, không phải container khác.

yaml
# Service tên "db" → hostname phải là "db"
DATABASE_URL=postgres://user:pass@db:5432/myapp
#                                 ^^
#                                 Phải match tên service!

⚠️ Cạm bẫy

Vấn đề: Khai báo custom networks nhưng quên gắn service vào đúng network.

Hậu quả: Services nằm trên networks khác nhau → hoàn toàn cô lập → không thể giao tiếp.

Giải pháp: Nếu không cần phân tách network, đừng khai báo custom networks — Compose tự tạo default network cho tất cả services. Nếu cần phân tách, đảm bảo services cần giao tiếp nằm trên cùng ít nhất một network.


💡 Tips & Performance

🚀 docker compose watch — Live reload cho development

Từ Compose 2.22+, bạn có thể dùng docker compose watch để tự động sync code thay đổi vào container — không cần rebuild:

yaml
services:
  api:
    build: .
    develop:
      watch:
        - action: sync               # Sync file thay đổi vào container
          path: ./src
          target: /app/src

        - action: rebuild             # Rebuild image khi package.json thay đổi
          path: ./package.json
bash
# Thay thế bind mount + nodemon
docker compose watch

Lợi ích: Nhanh hơn bind mount trên macOS/Windows (tránh vấn đề filesystem performance), và rõ ràng hơn về intent.

💡 Kiểm tra cú pháp trước khi chạy

bash
# Validate compose.yml — phát hiện lỗi YAML trước khi dựng stack
docker compose config

# Chỉ xem services nào sẽ được tạo
docker compose config --services

# Xem file compose đã được resolve (sau variable substitution)
docker compose config --format json

💡 Tối ưu thời gian build với cache

bash
# Build song song tất cả services (nhanh hơn nhiều)
docker compose build --parallel

# Build chỉ service cần thiết
docker compose build api

# Force rebuild (bỏ qua cache)
docker compose build --no-cache api

🛑 Anti-patterns — Những điều KHÔNG BAO GIỜ làm

🛑 Anti-pattern 1: Hardcode password trong compose.yml và commit lên Git

yaml
# ❌ TUYỆT ĐỐI KHÔNG LÀM ĐIỀU NÀY
services:
  db:
    environment:
      POSTGRES_PASSWORD: MySecretPassword123!

Hậu quả: Mọi người có quyền truy cập repo đều thấy mật khẩu. Nếu repo public — cả internet thấy. Bot tự động quét GitHub repositories tìm credentials — bạn sẽ bị hack trong vòng vài phút.

yaml
# ✅ Dùng biến môi trường từ .env
services:
  db:
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}

🛑 Anti-pattern 2: Sử dụng links: (Deprecated)

yaml
# ❌ DEPRECATED — Không dùng nữa!
services:
  api:
    links:
      - db
      - redis

links: là tính năng từ Compose V1, đã bị deprecated. Docker network tự động cung cấp DNS resolution — links: hoàn toàn không cần thiết và chỉ gây nhầm lẫn.

yaml
# ✅ Không cần khai báo gì — DNS tự động hoạt động
services:
  api:
    depends_on:
      db:
        condition: service_healthy
    # Trong code: kết nối db:5432 — tự động resolve

🛑 Anti-pattern 3: Expose port DB/Cache ra ngoài host

yaml
# ❌ NGUY HIỂM — Database bị expose ra mạng
services:
  db:
    ports:
      - "5432:5432"     # Ai trên cùng mạng đều kết nối được!

Chỉ expose port cho services cần truy cập từ bên ngoài Docker (API, frontend). DB và Cache không bao giờ cần expose port trừ khi debug.

yaml
# ✅ DB không expose port — chỉ có service cùng network kết nối được
services:
  db:
    image: postgres:16-alpine
    # KHÔNG có "ports:" — an toàn

⚙️ Phần 10: Under the Hood — Compose hoạt động như thế nào?

10.1 Docker Network Bridge Mode

Khi bạn chạy docker compose up, đây là những gì xảy ra bên dưới:

1. Compose đọc compose.yml
2. Tạo Docker network kiểu "bridge" (mặc định):
   → docker network create taskflow_default --driver bridge

3. Với mỗi service, tạo container và gắn vào network:
   → docker create --name taskflow-db-1 --network taskflow_default postgres:16-alpine
   → docker create --name taskflow-redis-1 --network taskflow_default redis:7-alpine
   → docker create --name taskflow-api-1 --network taskflow_default my-api:latest

4. Docker daemon tạo DNS alias cho mỗi container:
   → "db"    → 172.18.0.2
   → "redis" → 172.18.0.3
   → "api"   → 172.18.0.4

5. Khởi động containers theo thứ tự depends_on:
   → Start db → đợi healthcheck → Start redis → Start api

10.2 DNS Resolution bên trong

Mỗi container nhận một embedded DNS resolver tại 127.0.0.11:53. Khi api gọi db:5432:

api container               Docker DNS (127.0.0.11)         db container
     │                              │                            │
     │  DNS query: "db"             │                            │
     │─────────────────────────────▶│                            │
     │                              │  Resolve: db → 172.18.0.2 │
     │  DNS response: 172.18.0.2    │                            │
     │◀─────────────────────────────│                            │
     │                                                           │
     │  TCP connect: 172.18.0.2:5432                             │
     │──────────────────────────────────────────────────────────▶│
     │                           Connection established          │
     │◀──────────────────────────────────────────────────────────│

10.3 Port Mapping vs Service-to-Service

┌─────────────────────────────────────────────────────┐
│             Docker Bridge Network                   │
│                                                     │
│  ┌─────────┐       ┌─────────┐      ┌─────────┐    │
│  │   api   │──────▶│   db    │      │  redis  │    │
│  │  :3000  │  TCP   │  :5432  │      │  :6379  │    │
│  │         │──────────────────────▶│         │    │
│  └────┬────┘       └─────────┘      └────┬────┘    │
│       │         (không cần ports:)        │         │
│       │                                   │         │
└───────┼───────────────────────────────────┼─────────┘
        │                                   │
   ports: 3000:3000                    ports: 6379:6379
        │                                   │
        ▼                                   ▼
   ┌─────────────────────────────────────────────┐
   │              HOST MACHINE                   │
   │    browser → localhost:3000                 │
   │    RedisInsight → localhost:6379            │
   └─────────────────────────────────────────────┘

Tóm tắt:

  • Port mapping (ports:) = Cầu nối giữa host machine và Docker network. Chỉ cần khi truy cập từ browser/tool bên ngoài
  • Service-to-service = Giao tiếp trực tiếp qua Docker network. Dùng tên service làm hostname. Không cần ports:

10.4 Tại sao localhost không hoạt động giữa containers?

yaml
# ❌ HIỂU SAI PHỔ BIẾN
environment:
  - DATABASE_URL=postgres://user:pass@localhost:5432/myapp

Mỗi container là một Linux namespace riêng biệt — có network stack riêng. localhost trong container api trỏ đến chính container api, không phải container db. Đó là lý do phải dùng tên service (db) thay vì localhost.


📝 Quiz: Kiểm tra kiến thức

🧠 Quiz

Câu 1: Trong Docker Compose, service api muốn kết nối PostgreSQL (service tên db). Connection string nào đúng?

  • [ ] A. postgres://user:pass@localhost:5432/myapp
  • [ ] B. postgres://user:pass@172.18.0.2:5432/myapp
  • [x] C. postgres://user:pass@db:5432/myapp
  • [ ] D. postgres://user:pass@postgres:5432/myapp

💡 Giải thích: Docker Compose tạo DNS tự động — hostname = tên service trong compose.yml. Service tên db → hostname là db. Không dùng localhost (trỏ đến chính container), không dùng IP (thay đổi mỗi lần restart), không dùng tên image (postgres).

🧠 Quiz

Câu 2: Sự khác biệt giữa depends_on: [db]depends_on: { db: { condition: service_healthy } }?

  • [ ] A. Không có sự khác biệt — cả hai đều đợi DB sẵn sàng
  • [ ] B. Phiên bản dài chỉ hỗ trợ trong Compose V1
  • [x] C. Phiên bản ngắn chỉ đợi container start; phiên bản dài đợi healthcheck pass
  • [ ] D. Phiên bản dài khiến Compose tự restart DB nếu nó crash

💡 Giải thích: depends_on: [db] chỉ đảm bảo container db được tạo trước — KHÔNG kiểm tra service bên trong đã sẵn sàng chưa. condition: service_healthy bắt buộc Docker đợi healthcheck pass mới khởi động service phụ thuộc. Đây là cách duy nhất tránh race condition.

🧠 Quiz

Câu 3: Khi nào service KHÔNG cần khai báo ports: trong compose.yml?

  • [ ] A. Khi service đó không lắng nghe trên port nào
  • [x] B. Khi service chỉ được truy cập bởi các services khác trong cùng Docker network
  • [ ] C. Khi service dùng UDP thay vì TCP
  • [ ] D. Khi service dùng image từ Docker Hub

💡 Giải thích: ports: chỉ map port từ container ra host machine. Service-to-service communication trong cùng Docker network không cần ports: — chúng giao tiếp trực tiếp qua internal network. VD: db không cần ports: ["5432:5432"] vì chỉ api cần kết nối, và api dùng db:5432 thông qua internal DNS.

🧠 Quiz

Câu 4: Tại sao nên tạo file .env.example và commit lên Git?

  • [ ] A. Để Docker Compose tự động sử dụng nếu không tìm thấy .env
  • [ ] B. Để CI/CD pipeline đọc giá trị mặc định
  • [x] C. Để team biết cần đặt những biến môi trường nào mà không lộ giá trị thực
  • [ ] D. Để Compose validate kiểu dữ liệu của biến

💡 Giải thích: .env chứa secrets thật → KHÔNG commit. .env.example chứa danh sách keys (không có giá trị nhạy cảm) → commit để team mới biết cần tạo những biến gì. Đây là convention phổ biến trong mọi dự án chuyên nghiệp.


Compose Development Checklist

✅ Checklist triển khai

Checklist trước khi push code có compose.yml:

  • [ ] .env đã thêm vào .gitignore
  • [ ] Tất cả secrets dùng variable substitution ${VAR} — không hardcode
  • [ ] Có file .env.example với danh sách keys (không có giá trị nhạy cảm)
  • [ ] Mỗi database service có healthcheck phù hợp
  • [ ] depends_on dùng condition: service_healthy cho services cần thời gian init
  • [ ] Chỉ expose ports: cho services cần truy cập từ host
  • [ ] Named volumes cho persistent data (DB, uploads)
  • [ ] restart: unless-stopped cho tất cả services
  • [ ] docker compose config chạy thành công (YAML hợp lệ)
  • [ ] docker compose up -d && docker compose ps — tất cả services healthy


🎯 Tổng kết & Bước tiếp theo

Docker Compose biến quy trình phát triển từ "mở 5 terminal gõ 5 lệnh dài" thành "một file YAML + một lệnh docker compose up". Ba trụ cột cần nhớ:

  1. Service Discovery — Container gọi nhau bằng tên service, DNS tự động
  2. Environment — Secrets trong .env, variable substitution ${VAR:-default}
  3. Healthchecksdepends_on + condition: service_healthy = không còn race condition

Bạn đã hoàn thành Phase 1 Docker track! 🎉 Tiếp theo, hãy thực hành tại Practice Lab hoặc tiến vào Module 3: Compose nâng cao để học thêm về multi-network architecture, secrets management, và deploy configuration.