Vấn đề mình gặp khi deploy Node.js lần đầu
Lần đầu dùng Docker Compose cho dự án thực tế, mình mắc khá nhiều lỗi cơ bản mà bây giờ nghĩ lại thấy buồn cười. Image build xong nặng hơn 1GB, container chạy được vài tiếng rồi tự dừng không rõ lý do, biến môi trường thì hard-code thẳng vào Dockerfile. Mọi thứ hoạt động trên máy local nhưng lên server là tèo.
Nếu bạn đang bắt đầu deploy Node.js bằng Docker và gặp đúng những chuyện đó — bài này viết cho bạn.
Tại sao Node.js hay gặp vấn đề khi container hóa?
Docker và Node.js không phải lúc nào cũng chơi tốt với nhau. Có mấy cái bẫy mà hầu hết ai mới bắt đầu đều dính phải:
- node_modules quá nặng: Thư mục này có thể lên tới vài trăm MB, nếu copy cả vào image là lãng phí.
- Dùng sai base image:
node:latestmặc định là Debian đầy đủ — nặng không cần thiết. - Process không phải PID 1: Node chạy trong container nhưng không nhận được tín hiệu
SIGTERMđúng cách, dẫn đến graceful shutdown thất bại. - Biến môi trường bị lộ: Hard-code
DB_PASSWORDvào Dockerfile rồi push lên GitHub là chuyện có thật — mình đã thấy tận mắt. - Chạy với quyền root: Không cần thiết, và nếu bị exploit thì thiệt hại nặng hơn nhiều so với non-root user.
Các cách deploy Node.js với Docker
Cách 1: Dockerfile đơn giản (không khuyến nghị cho production)
Cách mà hầu hết mọi người bắt đầu:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "index.js"]
Build và chạy:
docker build -t myapp .
docker run -p 3000:3000 myapp
Nó chạy được — nhưng image nặng tới ~1.1GB. node_modules từ máy host bị copy vào nguyên si, dễ gây lỗi native modules nếu OS khác nhau. Chưa kể không có xử lý signal tắt đúng cách.
Cách 2: Multi-stage build (tốt hơn nhưng vẫn thiếu)
Multi-stage giúp tách bước build và runtime, giảm đáng kể kích thước image:
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Runtime
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
CMD ["node", "index.js"]
Image giảm xuống còn ~200MB nhờ dùng alpine. Nhưng vẫn chạy với root, và chưa xử lý signal đúng.
Dockerfile production-ready: Template mình đang dùng thực tế
Sau nhiều lần debug và cải tiến dần, đây là Dockerfile mình đang chạy ổn định trên production:
Bước 1: Tạo .dockerignore
Tạo file này trước — bước hay bị quên nhưng quan trọng để tránh copy rác vào image:
node_modules
npm-debug.log
.git
.env
*.md
dist
.DS_Store
Bước 2: Dockerfile chuẩn production
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
FROM node:18-alpine
WORKDIR /app
# Tạo user riêng, không dùng root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Đổi chủ sở hữu file
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
# Dùng tini hoặc --init để xử lý PID 1 đúng
CMD ["node", "--max-old-space-size=512", "index.js"]
Bước 3: Docker Compose cho development và production
Compose giúp quản lý biến môi trường và volume gọn hơn nhiều so với gõ tay docker run với đống flag dài ngoằng:
version: '3.9'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
env_file:
- .env # Biến môi trường lấy từ file .env, không hard-code
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 512M
Bước 4: Thêm health check endpoint vào app
Route này cho Docker biết app đang sống và phản hồi được. Mình hay trả thêm uptime để tiện debug khi container mới restart:
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', uptime: process.uptime() });
});
Bước 5: Xử lý graceful shutdown
Phần này hay bị bỏ qua nhất — và cũng là nguyên nhân khiến request bị drop âm thầm mỗi lần deploy mới. Khi Docker gửi SIGTERM, app phải xử lý đúng cách thay vì chết đột ngột giữa chừng:
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM nhận được, đang tắt gracefully...');
server.close(() => {
console.log('Server đã đóng.');
process.exit(0);
});
});
Build và chạy
# Build image
docker compose build
# Chạy ở chế độ detached
docker compose up -d
# Xem log
docker compose logs -f app
# Xem health check status
docker inspect --format='{{json .State.Health}}' ten_container
Mẹo thực chiến để tránh đau đầu
- Luôn pin phiên bản: Dùng
node:18.20-alpinethay vìnode:18-alpineđể tránh bị break khi có patch release không tương thích. - Dùng
npm cithaynpm install:ciinstall đúng theopackage-lock.json, không tự nâng cấp dependencies — quan trọng cho reproducible build. - Không bao giờ commit file
.env: Thêm vào.gitignore, dùng.env.examplelàm template. - Giới hạn memory: Node.js mặc định có thể ăn hết RAM nếu có leak. Đặt
--max-old-space-sizevàdeploy.resources.limits.memorytrong Compose. - Layer caching: Copy
package*.jsontrước, chạynpm ci, rồi mới copy code. Docker cache layernode_moduleslại — build lại chỉ mất vài giây nếu không đổi dependencies.
Kiểm tra image sau khi build
# Xem size image
docker images myapp
# Kiểm tra process đang chạy với user nào
docker exec ten_container whoami
# Scan lỗ hổng bảo mật (nếu có Docker Scout)
docker scout cves myapp
Làm đúng các bước trên, image của bạn sẽ chỉ còn khoảng 150–250MB — giảm ~80% so với cách mặc định. Chạy với non-root user, có health check, tắt đúng cách khi restart. Mỗi cái nghe nhỏ nhặt, nhưng gộp lại là sự khác biệt giữa container chạy ổn định vài tháng và một con app cứ khởi động lại lúc 3 giờ sáng.

