Docker Compose: Gom hết stack vào một file, deploy bằng một lệnh
Quản lý multi-container apps không còn đau đầu khi bạn biết dùng Docker Compose đúng cách từ syntax cơ bản đến healthcheck, named volumes và networking.
Nguyễn Nhật Long
@nguyennhatlong1303
Nếu bạn đã quen với Docker rồi, chắc chắn đã từng rơi vào cảnh này: app cần chạy 3-4 container cùng lúc, mỗi cái một lệnh docker run dài ngoằng với đủ thứ flag, port mapping, volume, env var... Mỗi lần setup lại là một lần tra tấn. Docker Compose sinh ra để giải quyết đúng cái vấn đề đó.
Docker Compose là gì, nói thẳng không vòng vo
Đơn giản mà nói: Docker Compose là tool cho phép bạn định nghĩa toàn bộ stack của mình bao nhiêu service, config ra sao, kết nối với nhau thế nào trong một file YAML duy nhất. Xong rồi chạy docker compose up là mọi thứ bật lên. Muốn tắt thì docker compose down. Vậy thôi.
Theo kinh nghiệm của mình, Compose đặc biệt có giá trị ở môi trường dev local. Thay vì onboarding member mới phải ngồi setup từng thứ một, bạn chỉ cần commit file docker-compose.yml vào repo, người ta clone về chạy một lệnh là có ngay môi trường y chang của bạn. Tiết kiệm cả buổi setup.
Lưu ý nhỏ: Từ Docker Compose V2, lệnh làdocker compose(có space) thay vìdocker-compose(có gạch ngang). Nếu bạn đang dùng Docker Desktop bản mới thì V2 đã được bundle sẵn rồi, không cần cài thêm gì.
Anatomy của một file docker-compose.yml
Hãy xem qua cấu trúc cơ bản trước:
1services:2 app:3 build: .4 ports:5 - "3000:3000"6 environment:7 - NODE_ENV=development8 volumes:9 - .:/app10 depends_on:11 - postgres1213 postgres:14 image: postgres:16-alpine15 ports:16 - "5432:5432"17 environment:18 POSTGRES_DB: myapp19 POSTGRES_USER: user20 POSTGRES_PASSWORD: secret21 volumes:22 - postgres_data:/var/lib/postgresql/data2324volumes:25 postgres_data:
Các key quan trọng bạn cần nắm:
| Key | Tác dụng |
|---|---|
| `build` | Chỉ đường dẫn tới Dockerfile để build image |
| `image` | Dùng image có sẵn từ registry |
| `ports` | Map port `host:container` |
| `environment` | Set env var cho container |
| `volumes` | Mount volume hoặc bind mount |
| `depends_on` | Khai báo thứ tự khởi động |
| `networks` | Gắn service vào network cụ thể |
Ví dụ thực tế: Next.js + PostgreSQL + Redis
Mình sẽ lấy một stack khá phổ biến để demo Next.js app, PostgreSQL làm primary database, Redis làm cache/session store. Đây là kiểu setup mình hay dùng cho các side project:
1services:2 web:3 build:4 context: .5 dockerfile: Dockerfile6 ports:7 - "3000:3000"8 environment:9 DATABASE_URL: postgresql://user:secret@postgres:5432/myapp10 REDIS_URL: redis://redis:637911 NODE_ENV: development12 volumes:13 - .:/app14 - /app/node_modules15 - /app/.next16 depends_on:17 postgres:18 condition: service_healthy19 redis:20 condition: service_started21 networks:22 - app_network2324 postgres:25 image: postgres:16-alpine26 environment:27 POSTGRES_DB: myapp28 POSTGRES_USER: user29 POSTGRES_PASSWORD: secret30 volumes:31 - postgres_data:/var/lib/postgresql/data32 healthcheck:33 test: ["CMD-SHELL", "pg_isready -U user -d myapp"]34 interval: 10s35 timeout: 5s36 retries: 537 networks:38 - app_network3940 redis:41 image: redis:7-alpine42 volumes:43 - redis_data:/data44 networks:45 - app_network4647volumes:48 postgres_data:49 redis_data:5051networks:52 app_network:53 driver: bridge
Mình thấy cái này hay ở chỗ: trong code Next.js, bạn connect tới PostgreSQL bằng hostname postgres và Redis bằng hostname redis đúng tên service trong file Compose. Docker tự lo phần DNS resolution giữa các container trong cùng network. Không cần biết IP, không cần hardcode gì cả.
Environment variables đừng hardcode vào file Compose
Hardcode password vào docker-compose.yml rồi commit lên Git là một trong những sai lầm kinh điển. Cách đúng là dùng .env file:
File .env:
1POSTGRES_PASSWORD=supersecretpassword2POSTGRES_USER=myuser3POSTGRES_DB=production_db4NEXT_PUBLIC_API_URL=https://api.example.com
Docker Compose tự động đọc file .env ở cùng thư mục. Bạn dùng variable substitution trong file Compose như sau:
1services:2 postgres:3 image: postgres:16-alpine4 environment:5 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}6 POSTGRES_USER: ${POSTGRES_USER}7 POSTGRES_DB: ${POSTGRES_DB}
Hoặc nếu muốn load cả file env vào container, dùng env_file:
1services:2 web:3 build: .4 env_file:5 - .env6 - .env.local # override cho local dev
Anh em lưu ý: nhớ add .env vào .gitignore, chỉ commit file .env.example với các key placeholder để người khác biết cần set những gì.
depends_on + healthcheck: cái combo không thể thiếu
Đây là phần nhiều người bỏ qua rồi sau đó hỏi "tại sao app mình bị lỗi connect database lúc mới start?"
depends_on mặc định chỉ đảm bảo container khởi động theo thứ tự, không đảm bảo service bên trong đã sẵn sàng nhận kết nối. PostgreSQL cần vài giây để init xong trước khi accept connections. Nếu app của bạn connect ngay lập tức, nó sẽ fail.
Giải pháp là kết hợp healthcheck với condition: service_healthy:
1services:2 postgres:3 image: postgres:16-alpine4 healthcheck:5 test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]6 interval: 10s # check mỗi 10 giây7 timeout: 5s # timeout sau 5 giây8 retries: 5 # thử lại tối đa 5 lần9 start_period: 30s # cho postgres 30s để init trước khi bắt đầu check1011 web:12 depends_on:13 postgres:14 condition: service_healthy # đợi postgres healthy hẳn mới start
Với config này, Docker sẽ chạy healthcheck liên tục, và chỉ start web khi postgres báo healthy. Mình đã bị bug này mấy lần trước khi hiểu ra giờ project nào mình cũng setup healthcheck từ đầu.
Named volumes dữ liệu không mất khi restart
Bạn để ý trong file Compose ở trên có block volumes: ở level cao nhất:
1volumes:2 postgres_data:3 redis_data:
Đây là named volumes. Khác với bind mount (mount thư mục từ host vào container), named volumes được Docker quản lý hoàn toàn. Data được lưu trong Docker's storage area trên host, tồn tại độc lập với container lifecycle.
Khi bạn chạy docker compose down, container bị xóa nhưng named volumes vẫn còn. Data của PostgreSQL vẫn nguyên vẹn. Chỉ khi bạn chạy docker compose down -v thì volumes mới bị xóa theo.
| Lệnh | Containers | Volumes |
|---|---|---|
| `docker compose down` | Bị xóa | Giữ nguyên |
| `docker compose down -v` | Bị xóa | Bị xóa |
| `docker compose stop` | Dừng lại | Giữ nguyên |
Các lệnh CLI cần nhớ
Phần lớn workflow hàng ngày của bạn với Compose chỉ xoay quanh vài lệnh sau:
1# Start toàn bộ stack ở background2docker compose up -d34# Start và rebuild image (dùng khi đổi Dockerfile)5docker compose up -d --build67# Xem logs của tất cả service8docker compose logs -f910# Xem logs của một service cụ thể11docker compose logs -f web1213# Xem trạng thái các container14docker compose ps1516# Chạy lệnh trong container đang chạy17docker compose exec web sh18docker compose exec postgres psql -U user -d myapp1920# Dừng và xóa containers (giữ volumes)21docker compose down2223# Dừng và xóa cả volumes24docker compose down -v2526# Restart một service cụ thể27docker compose restart web2829# Scale service (chạy nhiều instance)30docker compose up -d --scale web=3
Mình hay dùng docker compose logs -f web nhất cực kỳ tiện để debug khi app có vấn đề, không cần phải docker ps rồi copy container ID rồi docker logs nữa.
Networking tự động magic mà bạn nên hiểu
Khi bạn không khai báo networks gì cả, Docker Compose tự tạo một default network cho project và gắn tất cả services vào đó. Tên network được đặt theo pattern {tên_thư_mục}_default.
Trong cùng network này, các container giao tiếp với nhau qua tên service như hostname. Nên trong code Next.js, connection string sẽ là:
1postgresql://user:secret@postgres:5432/myapp2 ^^^^^^^^3 tên service trong Compose
Nếu bạn có nhiều project Compose chạy cùng lúc và cần chúng giao tiếp với nhau, bạn phải define network explicitly và dùng external: true để share network. Nhưng đó là câu chuyện phức tạp hơn, để bài khác mình nói.
Một điều hay nữa: port bạn khai báo trong ports: là để host machine (máy bạn) access vào container. Còn các container trong cùng network nói chuyện với nhau qua internal port của container, không cần expose ra host. Ví dụ Redis dùng port 6379 nội bộ, bạn không cần ports: - "6379:6379" nếu chỉ có app trong cùng Compose stack dùng Redis thôi.
Một vài tip thực chiến
Sau mấy năm dùng Compose, mình rút ra vài thứ:
Override file cho từng môi trường: Bạn có thể có docker-compose.yml là base config, rồi docker-compose.override.yml cho dev local (tự động được merge), và docker-compose.prod.yml cho production. Chạy với -f flag để chỉ định:
1docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Watch mode (Compose Watch): Docker Compose gần đây có thêm tính năng watch tự động sync file thay đổi vào container mà không cần restart. Nếu bạn đang dùng bind mount cho dev thì chưa cần, nhưng với production-like setup thì rất tiện.
Resource limits: Trong môi trường shared hoặc CI, nên set memory/CPU limits để tránh một service ăn hết resource:
1services:2 web:3 deploy:4 resources:5 limits:6 memory: 512m7 cpus: '0.5'
Docker Compose không phải là silver bullet cho production deployment Kubernetes hay Docker Swarm mới là câu chuyện đó. Nhưng cho dev environment và các dự án nhỏ đến vừa, Compose là tool không thể thiếu. Một file YAML, một lệnh, toàn bộ stack chạy ngon đơn giản vậy thôi mà hiệu quả cực kỳ.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.
Thấy hay? Chia sẻ cho bạn bè!
