Đi đến nội dung
·27 phút đọc

Docker cho Node.js: Cấu Hình Production-Ready Mà Ít Ai Nói Đến

Multi-stage builds, non-root users, health checks, quản lý secrets và tối ưu kích thước image. Pattern Docker tôi dùng cho mọi triển khai Node.js production.

Chia sẻ:X / TwitterLinkedIn

Hầu hết các Dockerfile Node.js trên production đều tệ. Không phải kiểu "hơi chưa tối ưu". Ý tôi là chạy với quyền root, ship image 600MB có cả devDependencies bên trong, không có health check, và secrets được hardcode trong biến môi trường mà bất kỳ ai dùng docker inspect đều đọc được.

Tôi biết vì chính tôi đã viết những Dockerfile đó. Suốt nhiều năm. Chúng hoạt động, nên tôi không bao giờ đặt câu hỏi. Rồi một ngày, cuộc kiểm toán bảo mật phát hiện container của chúng tôi chạy ở PID 1 root với quyền ghi vào toàn bộ filesystem, và tôi nhận ra rằng "hoạt động" và "production-ready" là hai tiêu chuẩn rất khác nhau.

Đây là cấu hình Docker tôi hiện dùng cho mọi dự án Node.js. Không phải lý thuyết. Nó đang chạy các service đằng sau trang web này và nhiều trang khác tôi quản lý. Mỗi pattern ở đây tồn tại vì tôi đã bị đốt bởi phương án thay thế hoặc chứng kiến người khác bị đốt.

Tại Sao Dockerfile Hiện Tại Của Bạn Có Thể Sai#

Để tôi đoán Dockerfile của bạn trông thế nào:

dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]

Đây là kiểu "hello world" của Dockerfiles. Nó hoạt động. Nó cũng có ít nhất năm vấn đề sẽ gây hại cho bạn trong production.

Chạy Với Quyền Root#

Mặc định, Docker image node chạy với quyền root. Điều đó có nghĩa là tiến trình ứng dụng của bạn có đặc quyền root bên trong container. Nếu ai đó khai thác lỗ hổng trong ứng dụng của bạn — một bug path traversal, SSRF, một dependency có backdoor — họ có quyền root truy cập vào filesystem của container, có thể sửa đổi binary, cài đặt package, và có khả năng leo thang quyền tùy thuộc vào cấu hình container runtime.

"Nhưng container được cách ly mà!" Một phần thôi. Container escape là có thật. CVE-2024-21626, CVE-2019-5736 — đây là những lần thoát container thực tế. Chạy non-root là biện pháp phòng thủ theo chiều sâu. Không tốn gì cả và đóng lại cả một lớp tấn công.

Cài devDependencies Trên Production#

npm install không có flag sẽ cài tất cả. Test runner, linter, build tool, type checker — tất cả nằm trong production image. Điều này làm phình image thêm hàng trăm megabyte và tăng bề mặt tấn công. Mỗi package bổ sung là thêm một lỗ hổng tiềm năng mà Trivy hoặc Snyk sẽ cảnh báo.

COPY Tất Cả#

COPY . . sao chép toàn bộ thư mục dự án vào image. Bao gồm .git (có thể rất lớn), file .env (chứa secrets), node_modules (mà bạn sắp cài lại), file test, tài liệu, cấu hình CI — tất cả mọi thứ.

Không Có Health Check#

Không có lệnh HEALTHCHECK, Docker không biết ứng dụng của bạn có thực sự đang phục vụ traffic hay không. Tiến trình có thể đang chạy nhưng bị deadlock, hết bộ nhớ, hoặc mắc kẹt trong vòng lặp vô tận. Docker sẽ báo cáo container đang "running" vì tiến trình chưa thoát. Load balancer tiếp tục gửi traffic đến một container zombie.

Không Có Chiến Lược Layer Caching#

Sao chép mọi thứ trước khi cài dependencies có nghĩa là thay đổi một dòng code duy nhất sẽ vô hiệu hóa cache của npm install. Mỗi lần build đều cài lại tất cả dependencies từ đầu. Với dự án có nhiều dependencies, đó là 2-3 phút lãng phí mỗi lần build.

Hãy sửa tất cả những điều này.

Multi-Stage Builds: Cải Tiến Lớn Nhất#

Multi-stage builds là thay đổi có tác động lớn nhất bạn có thể thực hiện cho Dockerfile Node.js. Khái niệm rất đơn giản: dùng một stage để build ứng dụng, rồi chỉ copy các artifact cần thiết vào image cuối cùng sạch sẽ, tối giản.

Đây là sự khác biệt trong thực tế:

dockerfile
# Single stage: ~600MB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
 
# Multi-stage: ~150MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]

Stage builder có mọi thứ: Node.js đầy đủ, npm, build tool, source code, devDependencies. Stage runner chỉ có những gì cần thiết lúc runtime. Stage builder bị loại bỏ hoàn toàn — nó không nằm trong image cuối.

So Sánh Kích Thước Thực Tế#

Tôi đã đo trên một dự án Express.js API thực tế với khoảng 40 dependencies:

Cách tiếp cậnKích thước Image
node:20 + npm install1.1 GB
node:20-slim + npm install420 MB
node:20-alpine + npm ci280 MB
Multi-stage + alpine + chỉ production deps150 MB
Multi-stage + alpine + deps đã cắt tỉa95 MB

Giảm 10 lần so với cách tiếp cận ngây thơ. Image nhỏ hơn nghĩa là pull nhanh hơn, triển khai nhanh hơn, và ít bề mặt tấn công hơn.

Tại Sao Alpine?#

Alpine Linux dùng musl libc thay vì glibc, và không bao gồm cache quản lý package, tài liệu, hoặc hầu hết các tiện ích trong bản phân phối Linux chuẩn. Image base node:20-alpine khoảng 50MB so với 350MB cho node:20-slim và hơn 1GB cho node:20 đầy đủ.

Đánh đổi là một số package npm có native binding (như bcrypt, sharp, canvas) cần được biên dịch với musl. Trong hầu hết trường hợp điều này tự hoạt động — npm sẽ tải binary prebuilt đúng. Nếu gặp vấn đề, bạn có thể cài build dependencies trong stage builder:

dockerfile
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... phần còn lại của build

Các build tool này chỉ tồn tại trong stage builder. Chúng không có trong image cuối cùng.

Dockerfile Production Hoàn Chỉnh#

Đây là Dockerfile tôi dùng làm điểm xuất phát cho mọi dự án Node.js. Mỗi dòng đều có chủ đích.

dockerfile
# ============================================
# Stage 1: Cài đặt dependencies
# ============================================
FROM node:20-alpine AS deps
 
# Bảo mật: tạo thư mục làm việc trước mọi thứ khác
WORKDIR /app
 
# Cài dependencies dựa trên lockfile
# Chỉ copy file package trước — điều này quan trọng cho layer caching
COPY package.json package-lock.json ./
 
# ci tốt hơn install: nhanh hơn, nghiêm ngặt hơn, và tái tạo được
# --omit=dev loại trừ devDependencies khỏi stage này
RUN npm ci --omit=dev
 
# ============================================
# Stage 2: Build ứng dụng
# ============================================
FROM node:20-alpine AS builder
 
WORKDIR /app
 
# Copy file package và cài TẤT CẢ dependencies (bao gồm dev)
COPY package.json package-lock.json ./
RUN npm ci
 
# BÂY GIỜ copy source code — thay đổi ở đây không vô hiệu hóa cache npm ci
COPY . .
 
# Build ứng dụng (TypeScript compile, Next.js build, v.v.)
RUN npm run build
 
# ============================================
# Stage 3: Production runner
# ============================================
FROM node:20-alpine AS runner
 
# Thêm label cho metadata image
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
 
# Bảo mật: cài dumb-init để xử lý tín hiệu PID 1 đúng cách
RUN apk add --no-cache dumb-init
 
# Bảo mật: đặt NODE_ENV trước mọi thứ khác
ENV NODE_ENV=production
 
# Bảo mật: dùng non-root user
# Image node đã bao gồm user 'node' (uid 1000)
USER node
 
# Tạo thư mục app thuộc sở hữu của user node
WORKDIR /app
 
# Copy production dependencies từ stage deps
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
 
# Copy ứng dụng đã build từ stage builder
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
 
# Expose port (chỉ là tài liệu — không publish)
EXPOSE 3000
 
# Health check: curl không có sẵn trong alpine, dùng node
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
 
# Dùng dumb-init làm PID 1 để xử lý tín hiệu đúng cách
ENTRYPOINT ["dumb-init", "--"]
 
# Khởi động ứng dụng
CMD ["node", "dist/server.js"]

Để tôi giải thích những phần không rõ ràng.

Tại Sao Ba Stage Thay Vì Hai?#

Stage deps chỉ cài production dependencies. Stage builder cài mọi thứ (bao gồm devDependencies) và build ứng dụng. Stage runner copy production deps từ deps và code đã build từ builder.

Tại sao không cài production deps trong stage builder? Vì stage builder có devDependencies lẫn vào. Bạn sẽ phải chạy npm prune --production sau khi build, chậm hơn và kém tin cậy hơn so với việc cài production dependency sạch riêng biệt.

Tại Sao dumb-init?#

Khi bạn chạy node server.js trong container, Node.js trở thành PID 1. PID 1 có hành vi đặc biệt trong Linux: nó không nhận signal handler mặc định. Nếu bạn gửi SIGTERM đến container (đó là điều docker stop làm), Node.js ở PID 1 sẽ không xử lý nó theo mặc định. Docker đợi 10 giây, rồi gửi SIGKILL, ngay lập tức kết thúc tiến trình mà không có cleanup — không graceful shutdown, không đóng kết nối database, không hoàn thành request đang xử lý.

dumb-init đóng vai trò PID 1 và chuyển tiếp tín hiệu đúng cách đến ứng dụng. Tiến trình Node.js nhận SIGTERM như mong đợi và có thể shutdown một cách nhẹ nhàng:

javascript
// server.js
const server = app.listen(3000);
 
process.on('SIGTERM', () => {
  console.log('Nhận SIGTERM, đang shutdown nhẹ nhàng');
  server.close(() => {
    console.log('HTTP server đã đóng');
    // Đóng kết nối database, flush log, v.v.
    process.exit(0);
  });
});

Một phương án thay thế là flag --init trong docker run, nhưng đưa nó vào image đảm bảo nó hoạt động bất kể container được khởi động như thế nào.

File .dockerignore#

Điều này quan trọng ngang với bản thân Dockerfile. Không có nó, COPY . . gửi mọi thứ đến Docker daemon:

# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
.dockerignore
README.md
LICENSE
.github
.vscode
.idea
coverage
.nyc_output
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__
test
tests
docs
.husky
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
vitest.config.*

Mỗi file trong .dockerignore là một file sẽ không được gửi đến build context, không nằm trong image, và không vô hiệu hóa layer cache khi thay đổi.

Layer Caching: Đừng Đợi 3 Phút Mỗi Lần Build#

Docker build image theo từng layer. Mỗi lệnh tạo một layer. Nếu một layer chưa thay đổi, Docker dùng bản cached. Nhưng đây là chi tiết quan trọng: nếu một layer thay đổi, tất cả các layer sau đó đều bị vô hiệu hóa.

Đó là lý do thứ tự các lệnh cực kỳ quan trọng.

Thứ Tự Sai#

dockerfile
COPY . .
RUN npm ci

Mỗi khi bạn thay đổi bất kỳ file nào — một dòng duy nhất trong một file source — Docker thấy layer COPY . . đã thay đổi. Nó vô hiệu hóa layer đó và mọi thứ sau đó, bao gồm npm ci. Bạn cài lại tất cả dependencies mỗi lần thay đổi code.

Thứ Tự Đúng#

dockerfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Bây giờ npm ci chỉ chạy khi package.json hoặc package-lock.json thay đổi. Nếu bạn chỉ thay đổi source code, Docker dùng lại layer npm ci đã cached. Với dự án có 500+ dependencies, điều này tiết kiệm 60-120 giây mỗi lần build.

Cache Mount Cho npm#

Docker BuildKit hỗ trợ cache mount giữ npm cache giữa các lần build:

dockerfile
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

Điều này giữ npm download cache qua các lần build. Nếu dependency đã được tải ở lần build trước, npm dùng bản cached thay vì tải lại. Đặc biệt hữu ích trong CI nơi bạn build thường xuyên.

Để dùng BuildKit, đặt biến môi trường:

bash
DOCKER_BUILDKIT=1 docker build -t myapp .

Hoặc thêm vào cấu hình Docker daemon:

json
{
  "features": {
    "buildkit": true
  }
}

Dùng ARG Để Phá Cache#

Đôi khi bạn cần buộc một layer rebuild. Ví dụ, nếu bạn đang pull tag latest từ registry và muốn đảm bảo lấy phiên bản mới nhất:

dockerfile
ARG CACHE_BUST=1
RUN npm ci

Build với giá trị duy nhất để phá cache:

bash
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .

Dùng tiết kiệm. Mục đích của caching là tốc độ — chỉ phá cache khi có lý do.

Quản Lý Secrets: Đừng Đặt Secrets Trong Dockerfile#

Đây là một trong những sai lầm phổ biến và nguy hiểm nhất. Tôi thấy liên tục:

dockerfile
# ĐỪNG BAO GIỜ LÀM ĐIỀU NÀY
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456

Biến môi trường đặt bằng ENV trong Dockerfile được nướng vào image. Bất kỳ ai pull image đều có thể xem chúng bằng docker inspect hoặc docker history. Chúng cũng hiển thị trong mọi layer sau khi được đặt. Ngay cả khi bạn unset chúng sau, chúng vẫn tồn tại trong lịch sử layer.

Ba Cấp Độ Secrets#

1. Secrets build-time (Docker BuildKit)

Nếu bạn cần secrets trong quá trình build (như token npm registry riêng), dùng flag --secret của BuildKit:

dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
 
# Mount secret lúc build — nó không bao giờ được lưu trong image
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
    npm ci
 
COPY . .
RUN npm run build

Build với:

bash
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

File .npmrc có sẵn trong lệnh RUN nhưng không bao giờ được commit vào bất kỳ layer image nào. Nó không xuất hiện trong docker history hay docker inspect.

2. Secrets runtime qua biến môi trường

Cho secrets ứng dụng cần lúc runtime, truyền chúng khi khởi động container:

bash
docker run -d \
  -e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
  -e API_KEY="sk-live-abc123" \
  myapp

Hoặc với env file:

bash
docker run -d --env-file .env.production myapp

Chúng hiển thị qua docker inspect trên container đang chạy, nhưng không được nướng vào image. Ai pull image không lấy được secrets.

3. Docker secrets (Swarm / Kubernetes)

Cho quản lý secret đúng cách trong môi trường orchestration:

yaml
# docker-compose.yml (Swarm mode)
version: "3.8"
services:
  api:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
 
secrets:
  db_password:
    external: true
  api_key:
    external: true

Docker mount secrets dưới dạng file tại /run/secrets/<secret_name>. Ứng dụng đọc chúng từ filesystem:

javascript
import { readFileSync } from "fs";
 
function getSecret(name) {
  try {
    return readFileSync(`/run/secrets/${name}`, "utf8").trim();
  } catch {
    // Dùng biến môi trường làm fallback cho phát triển local
    return process.env[name.toUpperCase()];
  }
}
 
const dbPassword = getSecret("db_password");

Đây là cách tiếp cận an toàn nhất vì secrets không bao giờ xuất hiện trong biến môi trường, danh sách tiến trình, hoặc output kiểm tra container.

File .env và Docker#

Không bao giờ đưa file .env vào Docker image. .dockerignore của bạn nên loại trừ chúng (đó là lý do chúng ta liệt kê .env.env.* ở trên). Cho phát triển local với docker-compose, mount chúng lúc runtime:

yaml
services:
  api:
    env_file:
      - .env.local

Health Checks: Cho Docker Biết Ứng Dụng Thực Sự Hoạt Động#

Health check cho Docker biết ứng dụng có hoạt động đúng hay không. Không có nó, Docker chỉ biết tiến trình có đang chạy — chứ không phải nó có thực sự xử lý được request.

Lệnh HEALTHCHECK#

dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"

Giải thích các tham số:

  • --interval=30s: Kiểm tra mỗi 30 giây
  • --timeout=10s: Nếu kiểm tra mất hơn 10 giây, coi như thất bại
  • --start-period=40s: Cho ứng dụng 40 giây để khởi động trước khi đếm lỗi
  • --retries=3: Đánh dấu unhealthy sau 3 lần thất bại liên tiếp

Tại Sao Không Dùng curl?#

Alpine không có curl mặc định. Bạn có thể cài (apk add --no-cache curl), nhưng điều đó thêm binary vào image tối giản. Dùng Node.js trực tiếp nghĩa là không cần dependency bổ sung.

Cho health check nhẹ hơn nữa, bạn có thể dùng script chuyên dụng:

javascript
// healthcheck.js
const http = require("http");
 
const options = {
  hostname: "localhost",
  port: 3000,
  path: "/health",
  timeout: 5000,
};
 
const req = http.request(options, (res) => {
  process.exit(res.statusCode === 200 ? 0 : 1);
});
 
req.on("error", () => process.exit(1));
req.on("timeout", () => {
  req.destroy();
  process.exit(1);
});
 
req.end();
dockerfile
COPY --chown=node:node healthcheck.js ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD ["node", "healthcheck.js"]

Endpoint Health#

Ứng dụng cần endpoint /health để health check gọi đến. Đừng chỉ trả 200 — thực sự xác minh ứng dụng khỏe mạnh:

javascript
app.get("/health", async (req, res) => {
  const checks = {
    uptime: process.uptime(),
    timestamp: Date.now(),
    status: "ok",
  };
 
  try {
    // Kiểm tra kết nối database
    await db.query("SELECT 1");
    checks.database = "connected";
  } catch (err) {
    checks.database = "disconnected";
    checks.status = "degraded";
  }
 
  try {
    // Kiểm tra kết nối Redis
    await redis.ping();
    checks.redis = "connected";
  } catch (err) {
    checks.redis = "disconnected";
    checks.status = "degraded";
  }
 
  const statusCode = checks.status === "ok" ? 200 : 503;
  res.status(statusCode).json(checks);
});

Trạng thái "degraded" với 503 báo orchestrator ngừng định tuyến traffic đến instance này trong khi nó phục hồi, nhưng không nhất thiết kích hoạt restart.

Tại Sao Health Check Quan Trọng Với Orchestrator#

Docker Swarm, Kubernetes, và ngay cả docker-compose thuần với restart: always đều dùng health check để đưa ra quyết định:

  • Load balancer ngừng gửi traffic đến container unhealthy
  • Rolling update đợi container mới healthy trước khi dừng container cũ
  • Orchestrator có thể restart container bị unhealthy
  • Pipeline triển khai có thể xác minh triển khai thành công

Không có health check, rolling deployment có thể kill container cũ trước khi container mới sẵn sàng, gây downtime.

docker-compose Cho Phát Triển#

Môi trường phát triển nên gần với production nhất có thể, nhưng có sự tiện lợi của hot reload, debugger, và phản hồi tức thì. Đây là cấu hình docker-compose tôi dùng cho phát triển:

yaml
# docker-compose.dev.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
      args:
        NODE_VERSION: "20"
    ports:
      - "3000:3000"
      - "9229:9229"   # Node.js debugger
    volumes:
      # Mount source code cho hot reload
      - .:/app
      # Volume ẩn danh để giữ node_modules từ image
      # Ngăn node_modules của host ghi đè lên container
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:devpassword@db:5432/myapp_dev
      - REDIS_URL=redis://redis:6379
    env_file:
      - .env.local
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    command: npm run dev
 
  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: myapp_dev
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: devpassword
    volumes:
      # Named volume để dữ liệu bền vững qua các lần restart container
      - pgdata:/var/lib/postgresql/data
      # Script khởi tạo
      - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data
    command: redis-server --appendonly yes
 
  # Tùy chọn: UI quản trị database
  adminer:
    image: adminer:latest
    ports:
      - "8080:8080"
    depends_on:
      - db
 
volumes:
  pgdata:
  redisdata:

Pattern Phát Triển Chính#

Volume mount cho hot reload: Volume mount .:/app ánh xạ source code local vào container. Khi bạn lưu file, thay đổi ngay lập tức hiển thị bên trong container. Kết hợp với dev server theo dõi thay đổi (như nodemon hoặc tsx --watch), bạn có phản hồi tức thì.

Thủ thuật node_modules: Volume ẩn danh - /app/node_modules đảm bảo container dùng node_modules riêng (được cài trong quá trình build image) thay vì node_modules của host. Điều này quan trọng vì native module biên dịch trên macOS sẽ không hoạt động trong container Linux.

Phụ thuộc service: depends_on với condition: service_healthy đảm bảo database thực sự sẵn sàng trước khi ứng dụng cố kết nối. Không có điều kiện health check, depends_on chỉ đợi container khởi động — không phải service bên trong sẵn sàng.

Named volume: pgdataredisdata bền vững qua các lần restart container. Không có named volume, bạn sẽ mất database mỗi khi chạy docker-compose down.

Dockerfile Phát Triển#

Dockerfile phát triển đơn giản hơn production:

dockerfile
# Dockerfile.dev
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
 
WORKDIR /app
 
# Cài tất cả dependencies (bao gồm devDependencies)
COPY package*.json ./
RUN npm ci
 
# Source code được mount qua volume, không copy
# Nhưng vẫn cần cho lần build ban đầu
COPY . .
 
EXPOSE 3000 9229
 
CMD ["npm", "run", "dev"]

Không có multi-stage build, không tối ưu production. Mục tiêu là iteration nhanh, không phải image nhỏ.

Production Docker Compose#

Production docker-compose là con thú khác. Đây là những gì tôi dùng:

yaml
# docker-compose.prod.yml
services:
  app:
    image: ghcr.io/yourorg/myapp:${TAG:-latest}
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    env_file:
      - .env.production
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
      replicas: 2
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
      interval: 30s
      timeout: 10s
      start_period: 40s
      retries: 3
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    networks:
      - internal
      - web
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
 
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 1G
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
 
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: >
      redis-server
      --appendonly yes
      --maxmemory 256mb
      --maxmemory-policy allkeys-lru
    volumes:
      - redisdata:/data
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M
    networks:
      - internal
    logging:
      driver: "json-file"
      options:
        max-size: "5m"
        max-file: "3"
 
volumes:
  pgdata:
    driver: local
  redisdata:
    driver: local
 
networks:
  internal:
    driver: bridge
  web:
    external: true

Khác Biệt Gì So Với Phát Triển#

Chính sách restart: unless-stopped tự động restart container nếu nó crash, trừ khi bạn dừng nó chủ động. Điều này xử lý kịch bản "crash lúc 3 giờ sáng". Phương án thay thế always cũng sẽ restart container bạn dừng có chủ đích, thường không phải điều bạn muốn.

Giới hạn tài nguyên: Không có giới hạn, memory leak trong ứng dụng Node.js sẽ ngốn hết RAM trên host, có thể kill các container khác hoặc chính host. Đặt giới hạn dựa trên mức sử dụng thực tế cộng thêm headroom:

bash
# Theo dõi mức sử dụng thực tế để đặt giới hạn phù hợp
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"

Cấu hình logging: Không có max-sizemax-file, Docker log tăng không giới hạn. Tôi đã thấy server production hết dung lượng đĩa vì Docker log. json-file với rotation là giải pháp đơn giản nhất. Cho centralized logging, chuyển sang driver fluentd hoặc gelf:

yaml
logging:
  driver: "fluentd"
  options:
    fluentd-address: "localhost:24224"
    tag: "myapp.{{.Name}}"

Cách ly mạng: Mạng internal chỉ truy cập được bởi các service trong compose stack này. Database và Redis không bị expose ra host hoặc container khác. Chỉ service app được kết nối vào mạng web, mà reverse proxy (Nginx, Traefik) dùng để định tuyến traffic.

Không có port mapping cho database: Lưu ý dbredis không có ports trong cấu hình production. Chúng chỉ truy cập được qua mạng Docker internal. Trong phát triển, chúng ta expose để có thể dùng tool local (pgAdmin, Redis Insight). Trong production, không có lý do gì để chúng truy cập được từ ngoài mạng Docker.

Next.js Cụ Thể: Standalone Output#

Next.js có tính năng tối ưu Docker tích hợp mà nhiều người không biết: chế độ standalone output. Nó trace import của ứng dụng và chỉ copy những file cần thiết để chạy — không cần node_modules (dependencies được bundle).

Bật nó trong next.config.ts:

typescript
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  output: "standalone",
};
 
export default nextConfig;

Điều này thay đổi build output đáng kể. Thay vì cần toàn bộ thư mục node_modules, Next.js tạo server.js độc lập trong .next/standalone/ chỉ bao gồm dependencies thực sự được sử dụng.

Dockerfile Production Cho Next.js#

Đây là Dockerfile tôi dùng cho dự án Next.js, dựa trên ví dụ chính thức của Vercel nhưng có tăng cường bảo mật:

dockerfile
# ============================================
# Stage 1: Cài đặt dependencies
# ============================================
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
 
COPY package.json package-lock.json ./
RUN npm ci
 
# ============================================
# Stage 2: Build ứng dụng
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
 
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
# Tắt telemetry Next.js trong quá trình build
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN npm run build
 
# ============================================
# Stage 3: Production runner
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
 
RUN apk add --no-cache dumb-init
 
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
# Non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
# Copy asset tĩnh
COPY --from=builder /app/public ./public
 
# Thiết lập thư mục standalone output
# Tự động tận dụng output trace để giảm kích thước image
# https://nextjs.org/docs/advanced-features/output-file-tracing
RUN mkdir .next
RUN chown nextjs:nodejs .next
 
# Copy standalone server và file tĩnh
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
 
EXPOSE 3000
 
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
 
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
 
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

So Sánh Kích Thước Cho Next.js#

Cách tiếp cậnKích thước Image
node:20 + đầy đủ node_modules + .next1.4 GB
node:20-alpine + đầy đủ node_modules + .next600 MB
node:20-alpine + standalone output120 MB

Standalone output mang tính biến đổi. Image 1.4 GB thành 120 MB. Deploy mất 90 giây để pull giờ chỉ mất 10 giây.

Xử Lý File Tĩnh#

Chế độ standalone của Next.js không bao gồm thư mục public hay asset tĩnh từ .next/static. Bạn cần copy chúng rõ ràng (như chúng ta làm trong Dockerfile ở trên). Trong production, bạn thường muốn CDN phía trước:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  output: "standalone",
  assetPrefix: process.env.CDN_URL || undefined,
};

Nếu không dùng CDN, Next.js phục vụ file tĩnh trực tiếp. Standalone server xử lý ổn — chỉ cần đảm bảo file đúng vị trí (Dockerfile của chúng ta đảm bảo điều đó).

Sharp Cho Tối Ưu Hình Ảnh#

Next.js dùng sharp cho tối ưu hình ảnh. Trong production image dựa trên Alpine, bạn cần đảm bảo binary đúng có sẵn:

dockerfile
# Trong stage runner, trước khi chuyển sang non-root user
RUN apk add --no-cache --virtual .sharp-deps vips-dev

Hoặc tốt hơn, cài nó như production dependency và để npm xử lý binary theo platform:

bash
npm install sharp

Image node:20-alpine hoạt động với binary prebuilt linux-x64-musl của sharp. Trong hầu hết trường hợp không cần cấu hình đặc biệt.

Quét Image và Bảo Mật#

Build image nhỏ với non-root user là khởi đầu tốt, nhưng chưa đủ cho workload production nghiêm túc. Đây là cách đi xa hơn.

Trivy: Quét Image#

Trivy là trình quét lỗ hổng toàn diện cho container image. Chạy trong CI pipeline:

bash
# Cài trivy
brew install aquasecurity/trivy/trivy  # macOS
# hoặc
apt-get install trivy  # Debian/Ubuntu
 
# Quét image
trivy image myapp:latest

Output mẫu:

myapp:latest (alpine 3.19.1)
=============================
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

Node.js (node_modules/package-lock.json)
=========================================
Total: 2 (UNKNOWN: 0, LOW: 0, MEDIUM: 1, HIGH: 1, CRITICAL: 0)

┌──────────────┬────────────────┬──────────┬────────┬───────────────┐
│   Library    │ Vulnerability  │ Severity │ Status │ Fixed Version │
├──────────────┼────────────────┼──────────┼────────┼───────────────┤
│ semver       │ CVE-2022-25883 │ HIGH     │ fixed  │ 7.5.4         │
│ word-wrap    │ CVE-2023-26115 │ MEDIUM   │ fixed  │ 1.2.4         │
└──────────────┴────────────────┴──────────┴────────┴───────────────┘

Tích hợp trong CI để fail build khi có lỗ hổng nghiêm trọng:

yaml
# .github/workflows/docker.yml
- name: Build image
  run: docker build -t myapp:${{ github.sha }} .
 
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    exit-code: 1
    severity: CRITICAL,HIGH
    ignore-unfixed: true

Filesystem Chỉ Đọc#

Bạn có thể chạy container với filesystem root chỉ đọc. Điều này ngăn kẻ tấn công sửa đổi binary, cài tool, hoặc viết script độc hại:

bash
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /app/.next/cache \
  myapp:latest

Mount --tmpfs cung cấp thư mục tạm có thể ghi nơi ứng dụng thực sự cần ghi (file tạm, cache). Mọi thứ khác là chỉ đọc.

Trong docker-compose:

yaml
services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /app/.next/cache

Loại Bỏ Tất Cả Capabilities#

Linux capabilities là quyền hạn chi tiết thay thế mô hình root toàn quyền. Mặc định, Docker container nhận một tập con capabilities. Bạn có thể loại bỏ tất cả:

bash
docker run --cap-drop=ALL myapp:latest

Nếu ứng dụng cần bind port dưới 1024, bạn cần NET_BIND_SERVICE. Nhưng vì chúng ta dùng port 3000 với non-root user, không cần bất kỳ capability nào:

yaml
services:
  app:
    image: myapp:latest
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

no-new-privileges ngăn tiến trình leo thang quyền qua binary setuid/setgid. Đây là biện pháp phòng thủ theo chiều sâu không tốn gì cả.

Pin Digest Base Image#

Thay vì dùng node:20-alpine (luôn thay đổi), pin vào digest cụ thể:

dockerfile
FROM node:20-alpine@sha256:abcdef123456...

Lấy digest bằng:

bash
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpine

Điều này đảm bảo build 100% tái tạo được. Đánh đổi là bạn không tự động nhận bản vá bảo mật cho base image. Dùng Dependabot hoặc Renovate để tự động hóa cập nhật digest:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: docker
    directory: "/"
    schedule:
      interval: weekly

Tích Hợp CI/CD: Tổng Hợp Tất Cả#

Đây là workflow GitHub Actions hoàn chỉnh build, quét, và push Docker image:

yaml
# .github/workflows/docker.yml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      security-events: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha
            type=ref,event=branch
            type=semver,pattern={{version}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          exit-code: 1
          severity: CRITICAL,HIGH
          ignore-unfixed: true
 
      - name: Upload Trivy results
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

BuildKit Cache Trong CI#

Các dòng cache-from: type=ghacache-to: type=gha,mode=max dùng GitHub Actions cache làm Docker layer cache. Điều này có nghĩa CI build được hưởng lợi từ layer caching qua các lần chạy. Lần build đầu mất 5 phút; các lần build tiếp theo chỉ thay đổi code mất 30 giây.

Bẫy Thường Gặp và Cách Tránh#

Xung Đột node_modules Trong Image vs Host#

Nếu bạn volume-mount thư mục dự án vào container (-v .:/app), node_modules của host ghi đè container. Native module biên dịch trên macOS sẽ không hoạt động trong Linux. Luôn dùng thủ thuật anonymous volume:

yaml
volumes:
  - .:/app
  - /app/node_modules  # giữ node_modules của container

Xử Lý SIGTERM Trong Dự Án TypeScript#

Nếu bạn chạy TypeScript với tsx hoặc ts-node trong phát triển, xử lý tín hiệu hoạt động bình thường. Nhưng trong production, nếu bạn dùng JavaScript đã biên dịch với node, đảm bảo output biên dịch giữ lại signal handler. Một số build tool tối ưu hóa bỏ code "không dùng".

Giới Hạn Bộ Nhớ và Node.js#

Node.js không tự động tôn trọng giới hạn bộ nhớ Docker. Nếu container có giới hạn 512MB, Node.js vẫn cố dùng kích thước heap mặc định (khoảng 1.5 GB trên hệ thống 64-bit). Đặt max old space size:

dockerfile
CMD ["node", "--max-old-space-size=384", "dist/server.js"]

Để khoảng 25% headroom giữa giới hạn heap Node.js và giới hạn bộ nhớ container cho non-heap memory (buffer, native code, v.v.).

Hoặc dùng flag phát hiện tự động:

dockerfile
ENV NODE_OPTIONS="--max-old-space-size=384"

Vấn Đề Timezone#

Alpine dùng UTC mặc định. Nếu ứng dụng phụ thuộc vào timezone cụ thể:

dockerfile
RUN apk add --no-cache tzdata
ENV TZ=America/New_York

Nhưng tốt hơn: viết code không phụ thuộc timezone. Lưu mọi thứ ở UTC. Chỉ chuyển đổi sang giờ local ở tầng hiển thị.

Build Argument vs Biến Môi Trường#

  • ARG chỉ có sẵn trong quá trình build. Không lưu lại trong image cuối (trừ khi bạn copy sang ENV).
  • ENV lưu trong image và có sẵn lúc runtime.
dockerfile
# Cấu hình build-time
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
 
# Cấu hình runtime
ENV PORT=3000
 
# SAI: Điều này làm secret hiển thị trong image
ARG API_KEY
ENV API_KEY=${API_KEY}
 
# ĐÚNG: Truyền secret lúc runtime
# docker run -e API_KEY=secret myapp

Giám Sát Trong Production#

Cấu hình Docker chưa hoàn chỉnh nếu thiếu khả năng quan sát. Đây là stack giám sát tối thiểu nhưng hiệu quả:

yaml
# docker-compose.monitoring.yml
services:
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    ports:
      - "9090:9090"
    networks:
      - internal
 
  grafana:
    image: grafana/grafana:latest
    volumes:
      - grafana_data:/var/lib/grafana
    ports:
      - "3001:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
    networks:
      - internal
 
volumes:
  prometheus_data:
  grafana_data:

Expose metric từ ứng dụng Node.js dùng prom-client:

javascript
import { collectDefaultMetrics, Registry, Histogram } from "prom-client";
 
const register = new Registry();
collectDefaultMetrics({ register });
 
const httpRequestDuration = new Histogram({
  name: "http_request_duration_seconds",
  help: "Thời lượng HTTP request tính bằng giây",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
  registers: [register],
});
 
// Middleware
app.use((req, res, next) => {
  const end = httpRequestDuration.startTimer();
  res.on("finish", () => {
    end({ method: req.method, route: req.route?.path || req.path, status_code: res.statusCode });
  });
  next();
});
 
// Endpoint metric
app.get("/metrics", async (req, res) => {
  res.set("Content-Type", register.contentType);
  res.end(await register.metrics());
});

Danh Sách Kiểm Tra#

Trước khi ship ứng dụng Node.js container hóa lên production, hãy xác minh:

  • Non-root user — Container chạy với non-root user
  • Multi-stage build — devDependencies và build tool không nằm trong image cuối
  • Alpine base — Dùng base image tối giản
  • .dockerignore.git, .env, node_modules, test được loại trừ
  • Layer cachingpackage.json được copy trước source code
  • Health check — Lệnh HEALTHCHECK trong Dockerfile
  • Xử lý tín hiệudumb-init hoặc --init cho xử lý SIGTERM đúng cách
  • Không secret trong image — Không ENV với giá trị nhạy cảm trong Dockerfile
  • Giới hạn tài nguyên — Giới hạn memory và CPU được đặt trong compose/orchestrator
  • Log rotation — Logging driver cấu hình với max size
  • Quét image — Trivy hoặc tương đương trong CI pipeline
  • Pin version — Phiên bản base image và dependency được pin
  • Giới hạn bộ nhớ--max-old-space-size được đặt cho Node.js heap

Hầu hết đều là cấu hình một lần. Làm một lần, tạo template, và mọi dự án mới bắt đầu với container production-ready từ ngày đầu.

Docker không phức tạp. Nhưng khoảng cách giữa Dockerfile "hoạt động" và production-ready rộng hơn hầu hết mọi người nghĩ. Các pattern trong hướng dẫn này thu hẹp khoảng cách đó. Dùng chúng, điều chỉnh chúng, và đừng triển khai container root với image 1GB không có health check nữa. Bản thân tương lai — người bị gọi lúc 3 giờ sáng — sẽ cảm ơn bạn.

Bài viết liên quan