Bạn clone một dự án web về máy, mở ra thấy một đống thư mục lạ và một file tên docker-compose.yml. Gõ đúng một dòng, vài chục giây sau cả trang web chạy được ở localhost:3000, kèm một database và một API, dù bạn chưa cài Postgres hay Python nào trên máy. Docker làm được điều đó.
Bài này đi qua đúng một dự án thật (chính trang blog bạn đang đọc) để thấy Docker làm gì và các mảnh ghép ăn khớp ra sao. Cách tiếp cận: đọc code trước, gọi tên khái niệm sau, và mỗi thuật ngữ mới sẽ được định nghĩa ngay tại chỗ trước khi đi tiếp.
1. Docker giải quyết vấn đề gì
Phần mềm hay dính câu "máy tôi chạy được mà". Máy bạn có Python 3.12, máy đồng nghiệp có 3.10; server thì thiếu một thư viện hệ thống. Cùng một đoạn code, ba máy ba kết quả. Nguyên nhân là app không sống một mình: nó phụ thuộc vào cả một môi trường xung quanh (phiên bản ngôn ngữ, thư viện, cấu hình hệ điều hành).
Docker xử lý bằng cách đóng gói app CÙNG toàn bộ môi trường của nó vào một khối chạy giống hệt nhau ở mọi máy. Khối đóng gói đó gọi là image, và khi đem ra chạy thì thành container.
Image: bản đóng gói tĩnh gồm app và mọi thứ cần để chạy nó (một hệ điều hành thu nhỏ, runtime của ngôn ngữ, các thư viện). Nó nằm im trên đĩa, chưa chạy.
Container: một image đang chạy. Từ một image có thể tạo ra nhiều container, giống như từ một class (khuôn) tạo ra nhiều object (vật thể). Lệnh
docker run <image>chính là bước "lấy khuôn đúc ra một vật thể sống".
Quan hệ một chiều: một image sinh ra nhiều container, còn một container luôn chạy từ đúng một image. Nắm hai từ này là đủ để đọc tiếp.
2. Nhìn toàn cảnh dự án trước
Trước khi mổ từng file, hãy xem dự án có những gì. Một trang web thường có ba phần việc tách biệt, và dự án này cũng vậy.
aie-blog/
├── docker-compose.yml # điều phối cả ba service
├── backend/ # service api, viết bằng FastAPI
│ ├── Dockerfile # công thức build image api
│ └── app/
└── frontend/ # service web, viết bằng Next.js
├── Dockerfile # công thức build image web
└── app/
# db: không có thư mục, dùng thẳng image postgres:16 từ Docker HubBa phần việc đó là:
- web: phần giao diện người dùng nhìn thấy, viết bằng Next.js. Nằm trong thư mục
frontend/. - api: phần xử lý logic và trả dữ liệu, viết bằng FastAPI (Python). Nằm trong thư mục
backend/. - db: phần lưu dữ liệu, dùng PostgreSQL. Nó KHÔNG có thư mục riêng, vì ta dùng thẳng một image có sẵn thay vì tự viết.
Mỗi phần việc như vậy, trong Docker, gọi là một service.
Service: một thành phần chạy độc lập trong hệ thống. Ở đây có ba service là web, api, db. Việc mô tả từng service (dựng thế nào, chạy ra sao, nối với nhau ra sao) nằm gọn trong một file duy nhất:
docker-compose.yml.
Nhớ mặt ba cái tên web, api, db từ đây: ở mục 3, chúng xuất hiện đúng nguyên văn làm tên ba service trong docker-compose.yml. Tên service bạn đặt trong file đó cũng chính là địa chỉ để các phần gọi nhau trong mạng nội bộ Docker (mục 4).
3. docker-compose.yml: đọc file thật rồi mới giải thích
Đây là file điều phối cả hệ thống, bản dùng cho môi trường phát triển:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
api:
build: ./backend
environment:
DATABASE_URL: ${DATABASE_URL}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN}
ports:
- "8000:8000"
depends_on:
- db
web:
build: ./frontend
environment:
API_URL: ${API_URL}
ports:
- "3000:3000"
depends_on:
- api
volumes:
db_data:docker-compose.yml: bản mô tả nhiều service chạy cùng nhau và cách chúng nối mạng, chia sẻ cấu hình, lưu dữ liệu. Một lệnh
docker compose upđọc file này và dựng cả hệ thống.
Ánh xạ file trên ra sơ đồ cho dễ hình dung:
Đọc từng khối trong file:
image: postgres:16(ở service db): lấy một image PostgreSQL phiên bản 16 dựng sẵn trên Docker Hub. Vì đã có sẵn nên chỉ cần tải về, không phải build.build: ./backendvàbuild: ./frontend: hai service này KHÔNG có image sẵn, phải tự dựng từ mã nguồn trong thư mục tương ứng (dựng bằng công thức trong Dockerfile, xem mục 5).environment: đặt biến cấu hình bên trong container lúc chạy (ví dụ mật khẩu database, địa chỉ api). Các giá trị${...}được lấy từ file.env, sẽ nói ở mục 6.ports: mở cửa cho bên ngoài truy cập vào container.volumesvàdb_data: nơi giữ dữ liệu bền cho database.depends_on: khai báo thứ tự khởi động.
Hai từ cuối cần định nghĩa rõ vì hay gây nhầm.
Port mapping (dạng
"3000:3000", host:container): mở một cổng của máy thật và nối nó vào một cổng bên trong container. Nhờ dòng"3000:3000"mà từ máy thật gõlocalhost:3000là chạm được vào web. Số bên trái là cổng máy thật, số bên phải là cổng trong container.
Volume: vùng lưu dữ liệu nằm NGOÀI container, sống độc lập với vòng đời container. Hệ thống file bên trong container là phù du:
docker compose downlà mất sạch.db_datagắn vào thư mục dữ liệu của Postgres nên xóa container dựng lại vẫn còn dữ liệu. Chỉdocker compose down -vmới xóa luôn volume.
Còn depends_on có một cái bẫy: nó chỉ bảo đảm thứ tự KHỞI ĐỘNG (db chạy trước api, api trước web), chứ KHÔNG bảo đảm service đã sẵn sàng nhận việc. Postgres có thể đã khởi động nhưng còn đang khởi tạo bên trong. Hệ thống thật cần thêm healthcheck để chờ tới lúc thực sự sẵn sàng (bản prod của dự án này có làm, còn bản dev thì giữ tối giản).
Lệnh chạy cả hệ thống:
docker compose up --buildCờ --build bảo Docker dựng lại các service có build: (api, web) trước khi chạy; service db chỉ cần tải image nên không bị dựng.
4. Cổng và mạng: các service tìm nhau bằng cách nào
Khi chạy, Docker Compose tạo MỘT mạng riêng nội bộ và bỏ cả ba container vào đó. Trong mạng này, mỗi container như một máy riêng, và Docker cấp sẵn một dịch vụ phân giải tên: tên service chính là địa chỉ để gọi.
Mạng nội bộ Docker: một mạng ảo riêng cho các container của cùng một dự án. Bên trong nó, gọi nhau bằng TÊN service (ví dụ
api:8000,db:5432), không dùnglocalhost. Docker tự dịch tên service sang đúng container, nên dù container bị dựng lại và đổi địa chỉ nội bộ, gọi theo tên vẫn trúng.
Điểm khiến người mới vấp nhiều nhất: trình duyệt KHÔNG nằm trong mạng Docker. Nó chạy trên máy thật của người dùng, nên nó không hiểu cái tên api. Hãy nhìn một request đi trọn vòng:
Đọc theo số:
- Trình duyệt gõ
localhost:3000để mở trang. Nó chỉ nói chuyện được với web, qua cổng đã mở bằng port mapping. - Trang blog là một Server Component, nghĩa là chính SERVER web (đang chạy trong mạng Docker) đi lấy dữ liệu, chứ không phải trình duyệt. Server web gọi
api:8000bằng tên service. - api chạy câu lệnh SQL tới
db:5432, cũng bằng tên service. - Dữ liệu trả ngược: db trả các dòng cho api, api gói thành JSON cho web, web dựng thành HTML gửi về trình duyệt.
Server Component: một trang Next.js mà phần lấy dữ liệu chạy trên máy chủ (server) trước khi gửi HTML về, thay vì để trình duyệt tự gọi. Vì việc gọi
apidiễn ra ở server (trong mạng Docker) nên dùng tên service là hợp lệ.
Quy tắc gọn để nhớ: gọi từ phía server thì dùng tên service (api:8000); gọi từ phía trình duyệt thì phải dùng địa chỉ máy thật thấy được (localhost:8000). Lý do localhost không dùng được giữa các container: bên trong một container, localhost nghĩa là chính container đó. Nếu web gọi localhost:8000, nó tự tìm cổng 8000 trong chính web, chứ không tới api.
Một điểm nữa: biến môi trường như API_URL=http://api:8000 chỉ là "tấm bản đồ ghi địa chỉ", nó không tự tạo kết nối. Chính hàm fetch() (bên web) và thư viện truy vấn SQL (bên api) mới mở kết nối thật. Ba vai tách biệt: biến môi trường nói địa chỉ nào, code thực hiện cuộc gọi, còn mạng Docker cho phép tìm nhau bằng tên.
5. Dockerfile: công thức build ra một image
Service db dùng image có sẵn, nhưng api và web thì phải tự dựng. Công thức dựng nằm trong file Dockerfile của mỗi thư mục.
Dockerfile: một danh sách bước tuần tự để dựng ra MỘT image. Mỗi dòng lệnh tạo ra một lớp (layer) xếp chồng lên nhau. Docker nhớ (cache) từng lớp, nên lần build sau chỉ dựng lại lớp nào có thay đổi.
Xem Dockerfile của backend, nó ngắn và dễ đọc:
FROM python:3.12-slim
# Install uv, the Python environment and dependency manager.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
# Install dependencies first so this layer caches when only source changes.
COPY pyproject.toml ./
RUN uv sync --no-dev
# Copy application source.
COPY app ./app
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]Vài điểm đáng học ở đây:
FROM python:3.12-slim: mọi image dựng từ một image nền. Ở đây nền là Python 3.12 bản gọn.- Thứ tự CỐ Ý: copy
pyproject.toml(file khai báo thư viện) và cài thư viện TRƯỚC, rồi mới copy mã nguồnapp. Vì Docker cache theo lớp, khi bạn chỉ sửa code mà không đổi thư viện, lớp cài thư viện được tái dùng, build nhanh hơn nhiều. Đảo thứ tự thì mỗi lần sửa code sẽ cài lại toàn bộ thư viện, rất phí. EXPOSE 8000vàCMD ...: khai báo cổng service lắng nghe và lệnh chạy khi container khởi động.
WORKDIR /app: đặt
/applàm thư mục làm việc mặc định trong image. Vì thếCOPY app ./applà copy vào/app/app, và mọi đường dẫn tương đối sau đó tính từ/app.
Có một điểm dễ hiểu lầm về build: ./frontend trong compose. Thư mục frontend được đặt làm "gốc" khi build (gọi là build context), nên bên trong image, NỘI DUNG của frontend/ nằm thẳng ở /app, chứ không tạo ra /app/frontend/. Cái tên frontend biến mất ở ranh giới đó.
Dockerfile của frontend nhỉnh hơn một bậc: nó chia làm hai chặng (multi-stage).
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/content ./content
EXPOSE 3000
CMD ["node", "server.js"]Multi-stage build: một Dockerfile có nhiều chặng (
FROM ... AS ten). Chặng đầu là nơi làm việc bừa bộn (cài đủ thư viện, biên dịch), chặng cuối chỉ chép sang những thành phẩm cần để chạy. Chỉ image của chặng cuối được giữ lại; các chặng tạm bị bỏ tự động, không phải xóa tay.
Vì sao frontend cần hai chặng còn backend thì không? Vì Python chạy thẳng file .py, không cần biên dịch. Còn Next.js (TypeScript và JSX) phải qua một bước npm run build để dịch sang JavaScript, gộp và tối ưu file, dựng sẵn trang. Sau bước đó, image chỉ cần THÀNH PHẨM (.next/standalone và bạn hữu), không cần mã nguồn gốc lẫn đống thư viện dev. Tách hai chặng giúp image cuối nhỏ hơn nhiều và ít bề mặt lỗi.
Nếu bạn quen Python: nó giống việc dùng một môi trường dev đầy đủ để đóng gói ra một wheel, rồi chỉ mang wheel sang môi trường production tối giản. Không ai mang cả môi trường dev đi ship.
Dòng cuối cũng đáng chú ý: bản build "standalone" của Next.js không tự gói các file bài viết trong content/, nên Dockerfile phải chép thêm content vào để server đọc lúc chạy. Đây là chi tiết riêng của dự án này, nhưng nó minh họa đúng tinh thần: image cuối chứa vừa đủ thứ cần để chạy.
6. .env: hai cơ chế khác nhau, hay bị gộp làm một
Trong compose bạn thấy nhiều ${...}. Giá trị của chúng đến từ file .env đặt cạnh docker-compose.yml:
POSTGRES_USER=aie
POSTGRES_PASSWORD=aie
POSTGRES_DB=aie_blog
DATABASE_URL=postgresql+psycopg://aie:aie@db:5432/aie_blog
FRONTEND_ORIGIN=http://localhost:3000
API_URL=http://api:8000Điểm hay nhầm là có HAI cơ chế khác hẳn nhau, dù trông đều liên quan tới biến:
- File
.envcạnh compose: Compose TỰ ĐỘNG đọc file này (một quy ước, không cần khai lệnh load) và thay các${POSTGRES_USER},${API_URL}... ngay trong file compose. Việc thay diễn ra lúc đọc file compose, trước khi container chạy. - Khối
environment:trong compose: đây là cơ chế thứ hai, đặt biến môi trường BÊN TRONG container lúc chạy, để code trong container đọc được.
Nói gọn: .env cấp giá trị cho chính file compose; environment: bơm biến vào trong container. Một cái là "điền chỗ trống trong bản mô tả", một cái là "trang bị cho container lúc sống".
Để ý DATABASE_URL trỏ tới @db:5432 và API_URL trỏ tới http://api:8000: cả hai dùng TÊN SERVICE, đúng như mục 4, vì đó là các cuộc gọi diễn ra bên trong mạng Docker.
7. Bảng tra nhanh
Gom lại các vai để khi quên thì liếc một cái là nhớ:
| Thành phần | Vai trò |
|---|---|
| image | bản đóng gói tĩnh, khuôn để tạo container |
| container | một image đang chạy |
| volume | vùng dữ liệu bền, sống ngoài vòng đời container |
| service | một thành phần trong hệ thống (web, api, db) |
| Dockerfile | công thức dựng ra một image |
| docker-compose.yml | bản điều phối nhiều service chạy cùng nhau |
| ports (host:container) | cửa cho máy thật vào container |
| tên service | địa chỉ để các container gọi nhau trong mạng Docker |
| .env | cấp giá trị cho các ${...} trong file compose |
| environment | đặt biến môi trường bên trong container lúc chạy |
| depends_on | thứ tự khởi động, KHÔNG bảo đảm đã sẵn sàng |
Nắm bảng này là bạn đọc được phần lớn cấu hình Docker của một dự án web thường gặp. Bước tiếp theo tự nhiên là xem cùng dự án đó lên server thật ra sao (bản compose.prod.yml, image dựng sẵn, đưa ra Internet qua Cloudflare), nhưng đó là câu chuyện của một bài khác.
