Qua 5 bài trước, chúng ta đã đi qua từng mảnh ghép của Docker: Image, Container, Networking, Volume và Swarm. Nhưng khi bắt tay vào một dự án thật, câu hỏi thường gặp lại là: “Ghép tất cả lại với nhau như thế nào cho đúng?”
Bài viết này sẽ đi từ đầu đến cuối quy trình đóng gói và triển khai một ứng dụng web gồm App + PostgreSQL + Redis lên server, theo cách mà các dự án thực tế đang làm.
Ứng Dụng Mẫu
Để tập trung vào Docker, chúng ta dùng một API Node.js rất đơn giản:
GET /: tăng bộ đếm lượt truy cập trong Redis và lấy thời gian hiện tại từ PostgreSQL.GET /health: kiểm tra kết nối tới database và cache, dùng cho healthcheck.
Kiến trúc tổng quan:
┌──────────────── network: backend ────────────────┐
Client ──:3000──▶ app (Node.js) ──▶ db (PostgreSQL) ── volume: db_data
│ ──▶ redis (Redis) ── volume: redis_data
└──────────────────────────────────────────────────┘
Cấu trúc thư mục:
myapp/
├── src/
│ └── index.js
├── package.json
├── package-lock.json
├── Dockerfile
├── .dockerignore
├── docker-compose.yml
└── .env
package.json:
{
"name": "myapp",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "^4.21.0",
"pg": "^8.13.0",
"redis": "^4.7.0"
}
}
Chạy
npm installmột lần để sinh ra filepackage-lock.json. Dockerfile bên dưới dùngnpm cinên bắt buộc phải có file này.
src/index.js:
const express = require("express");
const { Pool } = require("pg");
const { createClient } = require("redis");
const app = express();
const port = process.env.PORT || 3000;
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const cache = createClient({
url: process.env.REDIS_URL,
disableOfflineQueue: true, // báo lỗi ngay khi mất kết nối thay vì treo request
});
// Bắt lỗi mất kết nối, nếu không Node.js sẽ crash khi DB/Redis restart
db.on("error", (err) => console.error("PostgreSQL error:", err.message));
cache.on("error", (err) => console.error("Redis error:", err.message));
app.get("/health", async (req, res) => {
try {
await db.query("SELECT 1");
await cache.ping();
res.json({ status: "ok" });
} catch (err) {
res.status(503).json({ status: "error", message: err.message });
}
});
app.get("/", async (req, res) => {
try {
const visits = await cache.incr("visits");
const { rows } = await db.query("SELECT NOW() AS now");
res.json({ visits, dbTime: rows[0].now });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
async function start() {
await cache.connect();
app.listen(port, () => console.log(`App listening on port ${port}`));
}
start().catch((err) => {
console.error(err);
process.exit(1);
});
Lưu ý:
- Ứng dụng không hard-code bất kỳ địa chỉ hay mật khẩu nào. Mọi cấu hình đều đọc từ biến môi trường, và đây là điều kiện tiên quyết để một ứng dụng chạy tốt trong container.
- Hai dòng
.on("error", ...)không được bỏ qua. Trong môi trường container, database hay Redis có thể restart bất cứ lúc nào. Nếu không bắt sự kiệnerror, Node.js sẽ crash ngay khi mất kết nối, thay vì báo lỗi qua/healthrồi tự kết nối lại.
Bước 1: Viết Dockerfile Chuẩn Production
# Stage 1: cài dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 2: image chạy thực tế
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "src/index.js"]
Những điểm quan trọng:
| Kỹ thuật | Tác dụng |
|---|---|
Image base alpine |
Image nhỏ hơn nhiều lần so với bản đầy đủ, pull nhanh, ít lỗ hổng hơn |
| Multi-stage build | Stage cuối chỉ chứa những gì cần để chạy. Với TypeScript, ta thêm một stage build để biên dịch, rồi chỉ copy thư mục dist sang |
Copy package*.json trước, code sau |
Tận dụng cache layer: chỉ khi dependencies thay đổi thì mới phải chạy lại npm ci |
npm ci --omit=dev |
Cài đúng phiên bản trong lock file, bỏ qua devDependencies |
USER node |
Không chạy ứng dụng bằng root, giảm thiệt hại nếu ứng dụng bị khai thác |
HEALTHCHECK |
Docker biết container có thực sự “khỏe” hay không, không chỉ là “đang chạy” |
Tiếp theo là file .dockerignore, giúp tránh copy những thứ không cần thiết (hoặc nguy hiểm) vào image:
node_modules
npm-debug.log
.git
.env
Dockerfile
docker-compose*.yml
*.md
Đừng bao giờ để file
.envlọt vào image. Bất kỳ ai pull được image đều có thể đọc nó.
Bước 2: Định Nghĩa Toàn Bộ Stack Với Docker Compose
docker-compose.yml:
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
app:
image: ${APP_IMAGE:-myapp:latest}
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
networks:
- backend
logging: *default-logging
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- backend
logging: *default-logging
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- backend
logging: *default-logging
networks:
backend:
volumes:
db_data:
redis_data:
Giải thích, kèm liên hệ với các bài trước:
networks: backend(Bài 03): ba service nằm chung một network riêng nên gọi nhau bằng tên service (db,redis). Chỉappmở port ra ngoài, còn database và Redis không bị lộ ra internet.volumes: db_data, redis_data(Bài 04): dữ liệu vẫn còn khi container bị xóa hoặc khi build lại.depends_on+condition: service_healthy:appchỉ khởi động khi PostgreSQL và Redis đã sẵn sàng nhận kết nối, không chỉ là “đã start”. Đây là lỗi kinh điển khiến ứng dụng crash ngay lần chạy đầu tiên.restart: unless-stopped: container tự khởi động lại khi bị lỗi hoặc khi server reboot.logging: giới hạn mỗi file log 10MB, giữ tối đa 3 file. Không có cấu hình này, log có thể làm đầy ổ cứng server sau vài tháng.$${POSTGRES_USER}: hai dấu$để Compose không thay biến lúc đọc file, mà để shell bên trong container tự đọc.
Bước 3: Quản Lý Cấu Hình Và Bí Mật
Tạo file .env cùng thư mục với docker-compose.yml. Compose sẽ tự đọc file này:
POSTGRES_USER=myapp
POSTGRES_PASSWORD=doi-mat-khau-manh-o-day
POSTGRES_DB=myapp
# Chỉ cần khi deploy lên server (Bước 4–5); ở máy local để trống sẽ dùng myapp:latest
# APP_IMAGE=ghcr.io/<username>/myapp:1.0.0
Nguyên tắc:
- Thêm
.envvào.gitignore, chỉ commit file mẫu.env.example(không chứa giá trị thật). - Mật khẩu nằm trong
DATABASE_URLnên tránh các ký tự đặc biệt như@,:,/,#, hoặc phải URL-encode chúng. - Khi chạy trên Docker Swarm (Bài 05), nên chuyển sang dùng Docker secrets thay cho biến môi trường. Secret được mã hóa và chỉ được mount vào những service cần dùng.
Chạy thử trên máy local:
docker compose up -d --build
docker compose ps # cột STATUS phải hiện "healthy"
curl http://localhost:3000
# {"visits":1,"dbTime":"2026-10-27T08:00:00.000Z"}
Bước 4: Đưa Image Lên Registry
Server không nên build image, vì build tốn CPU/RAM và cần cả source code. Cách làm chuẩn là build một lần, đẩy lên registry, rồi server chỉ việc pull về.
Ví dụ với GitHub Container Registry (GHCR):
# Đăng nhập (dùng Personal Access Token có quyền write:packages)
echo $GHCR_TOKEN | docker login ghcr.io -u <username> --password-stdin
# Build và gắn tag theo phiên bản
docker build -t ghcr.io/<username>/myapp:1.0.0 .
# Đẩy lên registry
docker push ghcr.io/<username>/myapp:1.0.0
Luôn gắn tag theo phiên bản (hoặc commit SHA), đừng chỉ dùng
latest. Khi cần quay lại bản cũ, bạn chỉ việc đổi tag.
Bước 5: Triển Khai Lên Server
Trên server chỉ cần 2 file: docker-compose.yml và .env. Không cần source code. Nhớ bỏ comment dòng APP_IMAGE trong .env và điền đúng tên image vừa push.
mkdir -p ~/myapp && cd ~/myapp
# copy docker-compose.yml và .env lên đây (scp, rsync...)
docker login ghcr.io -u <username> # nếu image là private
docker compose pull
docker compose up -d --no-build
docker compose ps
Khi có phiên bản mới, chỉ cần sửa APP_IMAGE trong .env rồi chạy:
docker compose pull app
docker compose up -d --no-build app
Compose chỉ tạo lại container app. Database và Redis vẫn chạy bình thường, dữ liệu không bị ảnh hưởng.
Rollback khi bản mới gặp lỗi: đổi APP_IMAGE về tag cũ, rồi chạy lại hai lệnh trên.
Bước 6: Vận Hành Hằng Ngày
Xem log:
docker compose logs -f app # theo dõi realtime
docker compose logs --since 1h db # log 1 giờ gần nhất
Backup database:
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backup_$(date +%F).sql
Restore:
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"' < backup_2026-10-27.sql
Volume giúp dữ liệu không mất khi xóa container, nhưng không bảo vệ bạn khỏi việc hỏng ổ cứng hay lỡ tay xóa nhầm. Hãy đặt cron backup định kỳ và lưu bản backup ra ngoài server.
Dọn dẹp các image cũ không còn dùng:
docker image prune -f
Cẩn thận với
docker compose down -v: tham số-vsẽ xóa luôn các volume, tức là xóa toàn bộ dữ liệu database.
Checklist Trước Khi Lên Production
| Hạng mục | Kiểm tra |
|---|---|
| Dockerfile | Multi-stage, image base nhỏ, chạy bằng user non-root, có HEALTHCHECK |
.dockerignore |
Đã loại trừ .env, .git, node_modules |
| Cấu hình | Mọi thông tin nhạy cảm đọc từ biến môi trường, .env không nằm trong Git |
| Network | Chỉ service cần thiết mới mở port, database không lộ ra ngoài |
| Dữ liệu | Dùng named volume, có lịch backup và đã thử restore ít nhất một lần |
| Khởi động | depends_on có condition: service_healthy, có restart: unless-stopped |
| Log | Đã giới hạn max-size / max-file |
| Image | Gắn tag theo phiên bản, đẩy lên registry, server chỉ pull |
Tổng Kết
Một quy trình triển khai Docker thực tế xoay quanh ba ý chính:
- Đóng gói gọn và an toàn: Dockerfile multi-stage, non-root, có healthcheck.
- Mô tả toàn bộ hạ tầng bằng code: một file
docker-compose.ymlkết hợp network, volume và cấu hình qua.env. - Build một lần, chạy ở mọi nơi: image được gắn tag phiên bản và đẩy lên registry, server chỉ pull về và chạy.
Tuy vậy, các bước build, push rồi SSH vào server deploy vẫn đang làm thủ công. Bước tiếp theo là tự động hóa toàn bộ quy trình này với Jenkins CI/CD, nội dung thuộc series DevOps.
Đọc tiếp (series DevOps): Bài 01: Tự động build và deploy ứng dụng Docker với Jenkins CI/CD.