Bài 06: Triển khai ứng dụng thực tế với Docker

  • Đăng bởi JP
  • 25/10/2026

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 install một lần để sinh ra file package-lock.json. Dockerfile bên dưới dùng npm ci nê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ện error, Node.js sẽ crash ngay khi mất kết nối, thay vì báo lỗi qua /health rồ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 .env lọ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ỉ app mở 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: app chỉ 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 .env và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_URL nê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ố -v sẽ 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:

  1. Đóng gói gọn và an toàn: Dockerfile multi-stage, non-root, có healthcheck.
  2. Mô tả toàn bộ hạ tầng bằng code: một file docker-compose.yml kết hợp network, volume và cấu hình qua .env.
  3. 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.


Toàn bộ series


Trân trọng,
JP

Thảo luận trên tinh thần học hỏi