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.
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:
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ế:
# 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ận | Kích thước Image |
|---|---|
node:20 + npm install | 1.1 GB |
node:20-slim + npm install | 420 MB |
node:20-alpine + npm ci | 280 MB |
| Multi-stage + alpine + chỉ production deps | 150 MB |
| Multi-stage + alpine + deps đã cắt tỉa | 95 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:
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... phần còn lại của buildCá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.
# ============================================
# 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:
// 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#
COPY . .
RUN npm ciMỗ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#
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:
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:
DOCKER_BUILDKIT=1 docker build -t myapp .Hoặc thêm vào cấu hình Docker daemon:
{
"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:
ARG CACHE_BUST=1
RUN npm ciBuild với giá trị duy nhất để phá cache:
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:
# ĐỪNG BAO GIỜ LÀM ĐIỀU NÀY
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456Biế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:
# 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 buildBuild với:
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:
docker run -d \
-e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
-e API_KEY="sk-live-abc123" \
myappHoặc với env file:
docker run -d --env-file .env.production myappChú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:
# 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: trueDocker mount secrets dưới dạng file tại /run/secrets/<secret_name>. Ứng dụng đọc chúng từ filesystem:
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 và .env.* ở trên). Cho phát triển local với docker-compose, mount chúng lúc runtime:
services:
api:
env_file:
- .env.localHealth 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#
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:
// 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();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:
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:
# 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: pgdata và redisdata 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.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:
# 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: trueKhá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:
# 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-size và max-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:
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 ý db và redis 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:
// 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:
# ============================================
# 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ận | Kích thước Image |
|---|---|
node:20 + đầy đủ node_modules + .next | 1.4 GB |
node:20-alpine + đầy đủ node_modules + .next | 600 MB |
node:20-alpine + standalone output | 120 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:
// 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:
# Trong stage runner, trước khi chuyển sang non-root user
RUN apk add --no-cache --virtual .sharp-deps vips-devHoặc tốt hơn, cài nó như production dependency và để npm xử lý binary theo platform:
npm install sharpImage 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:
# Cài trivy
brew install aquasecurity/trivy/trivy # macOS
# hoặc
apt-get install trivy # Debian/Ubuntu
# Quét image
trivy image myapp:latestOutput 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:
# .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: trueFilesystem 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:
docker run --read-only \
--tmpfs /tmp \
--tmpfs /app/.next/cache \
myapp:latestMount --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:
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /app/.next/cacheLoạ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ả:
docker run --cap-drop=ALL myapp:latestNế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:
services:
app:
image: myapp:latest
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueno-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ể:
FROM node:20-alpine@sha256:abcdef123456...Lấy digest bằng:
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:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: docker
directory: "/"
schedule:
interval: weeklyTí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:
# .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.sarifBuildKit Cache Trong CI#
Các dòng cache-from: type=gha và cache-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:
volumes:
- .:/app
- /app/node_modules # giữ node_modules của containerXử 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:
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:
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ể:
RUN apk add --no-cache tzdata
ENV TZ=America/New_YorkNhư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#
ARGchỉ có sẵn trong quá trình build. Không lưu lại trong image cuối (trừ khi bạn copy sangENV).ENVlưu trong image và có sẵn lúc runtime.
# 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 myappGiá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ả:
# 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:
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 caching —
package.jsonđược copy trước source code - Health check — Lệnh HEALTHCHECK trong Dockerfile
- Xử lý tín hiệu —
dumb-inithoặc--initcho xử lý SIGTERM đúng cách - Không secret trong image — Không
ENVvớ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.