Docker và Kubernetes cho JavaScript developer: từ Dockerfile production đến Deployment chạy ổn định
Viết Dockerfile multi-stage cho Next.js standalone, xử lý SIGTERM và PID 1 đúng cách, dựng môi trường local bằng docker compose, rồi deploy lên Kubernetes với probe, resource limit, rolling update không rớt request và HPA.
Một tình huống rất phổ biến: một app Next.js và một service orders-api viết bằng Node.js, đóng gói bằng Dockerfile một stage, image hơn 1 GB vì chứa cả node_modules dev lẫn source. Mỗi lần deploy lên Kubernetes, dashboard lỗi lại nhảy một chùm 502 trong khoảng 10-20 giây. Thỉnh thoảng pod bị restart với OOMKilled dù heap trong metrics trông vẫn ổn. Và docker stop luôn mất đúng 10 giây mới dừng.
Cả ba triệu chứng có nguyên nhân rất cụ thể: image build sai thứ tự layer, process Node chạy làm PID 1 và không xử lý SIGTERM, Kubernetes gửi traffic vào pod đang tắt, và heap V8 không được cấu hình theo memory limit của container. Bài này đi qua từng bước để sửa chúng: Dockerfile production cho Next.js output: 'standalone', graceful shutdown, chọn base image, docker compose cho môi trường local, manifest Kubernetes đầy đủ với probe và resource, rolling update không rớt request, autoscaling, cách debug bằng kubectl, và khi nào bạn không cần Kubernetes.
Ví dụ dùng Node.js 24 (LTS), Next.js 15/16 với App Router, Docker có BuildKit (mặc định trong Docker Desktop và Docker Engine bản mới) và Kubernetes 1.30 trở lên.
TL;DR
- Multi-stage build: stage
depschỉ copypackage.json+ lockfile, nên layer cài dependency được cache cho tới khi lockfile đổi. - Next.js
output: 'standalone'cho image runtime không cầnnode_modulesđầy đủ. Service Node thường thì dùngnpm ci --omit=devở stage riêng. - Chạy bằng
CMD ["node", "server.js"](exec form) quatini, và xử lý SIGTERM: ngừng nhận connection, chờ request xong, đóng DB pool. - Kubernetes:
startupProbecho lúc khởi động,readinessProbequyết định nhận traffic,livenessProbechỉ kiểm tra process, không kiểm tra DB. --max-old-space-sizekhoảng 75% memory limit;preStopsleep vài giây +maxUnavailable: 0để deploy không rớt request.
Dockerfile production cho Next.js với output: 'standalone'
Bật standalone output trong config:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
}
export default nextConfig
Khi build, Next.js trace xem server thực sự import những file nào trong node_modules và copy đúng những file đó vào .next/standalone, cùng một file server.js tối giản. Thư mục này không chứa public/ và .next/static/, vì Next.js giả định bạn có thể phục vụ chúng qua CDN. Nếu không dùng CDN, bạn phải tự copy hai thư mục đó vào. Quên bước này là lỗi phổ biến nhất: trang render được nhưng toàn bộ CSS, JS và ảnh trả về 404.
Trước khi viết Dockerfile, tạo .dockerignore. Không có file này, COPY . . gửi cả node_modules local (build cho macOS), .next cũ và .env.local vào build context:
# .dockerignore
node_modules
.next
.git
.github
coverage
playwright-report
test-results
*.log
.env*
!.env.example
Dockerfile*
compose*.yaml
Dockerfile multi-stage:
# syntax=docker/dockerfile:1
ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-slim AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# 1) Dependencies: layer này chỉ build lại khi package.json hoặc lockfile đổi
FROM base AS deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
# 2) Build
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Biến NEXT_PUBLIC_* được nhúng vào bundle lúc build, không phải lúc chạy
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
RUN npm run build
# 3) Runtime: chỉ chứa output standalone
FROM base AS runner
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "server.js"]
Giải thích từng quyết định:
- Thứ tự
COPYquyết định cache. Docker cache theo từng layer: một layer bị build lại thì mọi layer sau nó cũng vậy. Copy lockfile trước rồinpm ci, sau đó mớiCOPY . ., nên sửa một file component chỉ làm chạy lại bước build, không phải cài lại toàn bộ dependency. --mount=type=cachegiữ thư mục cache của npm giữa các lần build trên cùng máy, kể cả khi lockfile đổi. Nó không nằm trong image cuối. Trên CI, kết hợp với cache của BuildKit (ví dụ--cache-from/--cache-tovới registry) để có lợi ích tương tự.- Stage
depsdùngnpm ciđầy đủ (cả devDependencies) vìnext buildcần TypeScript, PostCSS, Tailwind... Runtime không cần những thứ đó, và nhờ standalone nên stagerunnerkhông copynode_modulestừdeps. HOSTNAME=0.0.0.0:server.jsbind theo biếnHOSTNAME. Docker và Kubernetes tự đặt biến này bằng tên container hoặc tên pod, nên nếu không ghi đè, server chỉ lắng nghe trên địa chỉ ứng với hostname đó. Request tới127.0.0.1(như lệnhHEALTHCHECKbên dưới) sẽ bị từ chối.- User
nodecó sẵn trong image chính thức (uid 1000). Chạy bằng root nghĩa là một lỗ hổng RCE trong app có quyền root trong container.--chown=node:nodecần thiết cho.next, vì Next.js ghi cache ISR và ảnh tối ưu vào.next/cachelúc chạy. NEXT_PUBLIC_*là build-time. Một image build vớiNEXT_PUBLIC_API_BASE_URL=https://staging-api...sẽ mang URL đó lên production dù bạn đổi env lúc chạy. Hoặc build image riêng cho từng môi trường, hoặc chuyển cấu hình đó sang server (đọcprocess.envtrong Server Component hoặc route handler) để một image dùng được cho mọi nơi.
Service Node.js thường (không phải Next.js). Với orders-api viết bằng TypeScript, không có tracing như standalone, nên tách một stage chỉ cài production dependencies:
# syntax=docker/dockerfile:1
FROM node:24-slim AS base
WORKDIR /app
FROM base AS build
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build # tsc -> dist/
FROM base AS prod-deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM base AS runner
ENV NODE_ENV=production PORT=8080
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
# Code thuộc root, user node chỉ đọc được: app không thể tự sửa code của nó
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 8080
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/server.js"]
npm ci --omit=dev chạy ở stage riêng thay vì npm prune sau khi build, để layer production dependencies chỉ phụ thuộc lockfile và được cache độc lập với source. Nếu package có native module (ví dụ bcrypt), stage này cũng là nơi nó được build cho đúng libc của image runtime.
PID 1, tín hiệu và graceful shutdown
Khi Kubernetes (hay docker stop) muốn dừng container, nó gửi SIGTERM cho process có PID 1 trong container, chờ một khoảng thời gian, rồi gửi SIGKILL. Có ba cách làm hỏng bước này:
- Shell form.
CMD node server.js(không có ngoặc vuông) thực chất chạy/bin/sh -c "node server.js". Shell là PID 1, vàshkhông chuyển tiếp SIGTERM cho Node. Kết quả: app không biết mình sắp bị tắt, bị SIGKILL sau thời gian chờ, request đang chạy bị cắt ngang. Đây chính là lý dodocker stopluôn mất đúng 10 giây. npm startlàm entrypoint. Thêm một process trung gian, cách chuyển tiếp tín hiệu phụ thuộc version npm, và log khi tắt khó đọc. Gọi thẳngnode.- Node làm PID 1 mà không đăng ký handler. Linux không áp dụng hành vi mặc định "SIGTERM thì thoát" cho PID 1. Nếu code không có
process.on('SIGTERM', ...), tín hiệu bị bỏ qua.
tini giải quyết phần hạ tầng: nó làm PID 1, chuyển tiếp tín hiệu cho Node và dọn zombie process nếu app có spawn process con. Khi chạy bằng Docker thuần, docker run --init cho kết quả tương tự mà không cần cài gì, nhưng Kubernetes không có tùy chọn này, nên mình cài tini ngay trong image.
Phần còn lại là việc của app. Đây là graceful shutdown cho orders-api:
// src/server.ts
import http from 'node:http'
import pg from 'pg'
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 })
const SHUTDOWN_TIMEOUT_MS = 25_000
let shuttingDown = false
const server = http.createServer(async (req, res) => {
try {
if (req.url === '/healthz/live') {
res.writeHead(200).end('ok')
return
}
if (req.url === '/healthz/ready') {
res.writeHead(shuttingDown ? 503 : 200).end(shuttingDown ? 'draining' : 'ok')
return
}
if (req.method === 'GET' && req.url === '/orders') {
const { rows } = await pool.query(
'select id, status, total_amount from orders order by created_at desc limit 20',
)
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(rows))
return
}
res.writeHead(404).end()
} catch (error) {
console.error('request failed', error)
if (!res.headersSent) res.writeHead(500)
res.end()
}
})
// Dài hơn idle timeout của load balancer phía trước, tránh LB gửi request vào connection vừa bị đóng
server.keepAliveTimeout = 65_000
server.listen(Number(process.env.PORT ?? 8080), () => {
console.log('orders-api listening')
})
function shutdown(signal: NodeJS.Signals) {
if (shuttingDown) return
shuttingDown = true
console.log(`${signal} received, draining`)
const forceExit = setTimeout(() => {
console.error('drain timeout, closing remaining connections')
server.closeAllConnections()
process.exit(1)
}, SHUTDOWN_TIMEOUT_MS)
forceExit.unref()
// Ngừng nhận connection mới; callback chạy khi mọi connection đã đóng
server.close(async (err) => {
await pool.end()
console.log('shutdown complete')
process.exit(err ? 1 : 0)
})
// Đóng ngay các keep-alive connection đang rảnh, nếu không chúng giữ server mở
server.closeIdleConnections()
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
Những điểm cần để ý:
server.close()chỉ ngừng nhận connection mới. Request đang xử lý vẫn chạy tới khi xong. Keep-alive connection rảnh thì không tự đóng ngay, nên cầncloseIdleConnections()(có từ Node 18.2).- Timer ép buộc phải ngắn hơn thời gian Kubernetes cho phép (phần rolling update bên dưới), để app tự thoát có kiểm soát thay vì bị SIGKILL.
- Đóng DB pool sau khi server đã đóng, không phải trước. Đóng trước thì request đang chạy sẽ lỗi vì mất connection.
- Với Next.js standalone,
server.jscủa Next tự xử lý SIGTERM/SIGINT. Nếu bạn cần chạy logic riêng khi tắt (flush log, đóng connection), xem mục graceful shutdown trong tài liệu self-hosting của Next.js. Phần quan trọng nhất với Next.js trên Kubernetes làpreStopở phần dưới.
Kiểm tra nhanh: docker run --rm -p 8080:8080 orders-api, rồi ở terminal khác time docker stop <id>. Nếu thời gian gần bằng 10 giây (mặc định của docker stop), tín hiệu chưa đến được app.
Chọn base image: alpine, slim hay distroless
| Image | libc | Shell và package manager | Ưu điểm | Nhược điểm |
|---|---|---|---|---|
node:24-alpine |
musl | sh, apk |
Nhỏ nhất trong các lựa chọn có shell | Prebuilt binary cho glibc không chạy được; Node.js chỉ hỗ trợ musl ở mức experimental |
node:24-slim |
glibc (Debian) | bash, apt |
Tương thích tốt với native module và prebuilt binary (sharp, Prisma engine, bcrypt) | Lớn hơn alpine một chút |
node:24 (đầy đủ) |
glibc | Đủ công cụ build | Build được mọi native module | Rất lớn, nhiều package không cần, nhiều CVE trong scanner |
| Distroless Node.js | glibc | Không có shell | Ít package nhất, bề mặt tấn công nhỏ | Không exec sh vào debug được, cần kubectl debug; không chạy được apt-get install tini |
Mặc định của mình là slim. Những vấn đề với musl thường gặp: package có prebuilt binary chỉ cho glibc sẽ phải build từ source (cần thêm python3, make, g++ trong stage build) hoặc tải nhầm binary rồi crash lúc chạy; một số khác biệt về DNS resolver và hiệu năng cấp phát bộ nhớ so với glibc. Alpine vẫn hợp lý nếu dependency của bạn là JavaScript thuần và bạn đã test kỹ trên chính image đó. Đừng dev trên glibc rồi chỉ phát hiện vấn đề musl trên production.
Đo và tìm thứ làm image phình to:
docker build -t shop-web:local .
docker image ls shop-web
# REPOSITORY TAG IMAGE ID CREATED SIZE (output minh họa)
# shop-web local 4c1f0a9e2b77 12 seconds ago 268MB
docker history shop-web:local # kích thước từng layer, xem layer nào lớn bất thường
docker run --rm --entrypoint sh shop-web:local -c 'du -sh /app/* /app/.next/* | sort -h | tail'
dive shop-web:local # công cụ mã nguồn mở, duyệt nội dung từng layer
Khi image lớn bất thường, thủ phạm thường là: thiếu .dockerignore, copy node_modules từ stage deps sang runtime thay vì dùng standalone, hoặc một bước RUN tải file rồi xóa ở bước RUN sau (file vẫn nằm trong layer trước).
Về HEALTHCHECK. Lệnh này được Docker Engine và Docker Compose dùng (docker ps hiển thị healthy/unhealthy, depends_on dùng được). Kubernetes bỏ qua HEALTHCHECK trong image và chỉ dùng probe khai báo trong manifest. Image slim không có curl, nên ví dụ trên dùng fetch có sẵn trong Node thay vì cài thêm curl chỉ để healthcheck.
docker compose cho môi trường local với Postgres và Redis
Mục tiêu: docker compose up là có Postgres, Redis, migration chạy xong, rồi app mới khởi động.
# compose.yaml
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: shop
POSTGRES_PASSWORD: shop_local_only
POSTGRES_DB: shop
ports:
- '127.0.0.1:5432:5432' # chỉ mở cho máy local, không mở ra mạng LAN
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U shop -d shop']
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
redis:
image: redis:7-alpine
command: ['redis-server', '--appendonly', 'yes']
ports:
- '127.0.0.1:6379:6379'
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 10
migrate:
build:
context: .
target: deps # dùng lại stage deps của Dockerfile
command: ['npm', 'run', 'db:migrate']
environment:
DATABASE_URL: postgresql://shop:shop_local_only@db:5432/shop
volumes:
- ./:/app
- /app/node_modules
depends_on:
db:
condition: service_healthy
web:
build:
context: .
target: deps
command: ['npm', 'run', 'dev', '--', '--hostname', '0.0.0.0']
ports:
- '3000:3000'
environment:
DATABASE_URL: postgresql://shop:shop_local_only@db:5432/shop
REDIS_URL: redis://redis:6379
volumes:
- ./:/app # source từ máy, sửa là hot reload
- /app/node_modules # giữ node_modules của image (build cho Linux)
- /app/.next
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
volumes:
pgdata:
redisdata:
Các điểm hay sai:
depends_onkhông cóconditionchỉ đợi container được tạo, không đợi Postgres sẵn sàng nhận connection. App khởi động trước, connect thất bại, crash.condition: service_healthyđợi healthcheck đạt, cònservice_completed_successfullyđợi servicemigratechạy xong và thoát với mã 0.- Anonymous volume
/app/node_modules. Bind mount./:/appsẽ che mấtnode_modulescó trong image. Khai báo thêm volume ẩn danh tại/app/node_modulesgiữ lại bản của image, vốn được cài cho Linux. Không làm vậy thì container sẽ dùngnode_modulestừ macOS và native module crash. Cái giá: khi bạn thêm package, volume vẫn giữ bản cũ. Chạydocker compose up --build --renew-anon-volumes(viết tắt-V) để làm mới. - Hostname là tên service. Trong mạng của compose, app kết nối tới
db:5432, không phảilocalhost:5432.localhostbên trong container là chính container đó. - Volume có tên (
pgdata) giữ dữ liệu qua các lầndocker compose down. Muốn xóa sạch thìdocker compose down -v. - Mật khẩu trong file compose chỉ chấp nhận được vì nó là dữ liệu local, và port chỉ bind vào
127.0.0.1. Đừng dùng lại giá trị này ở bất kỳ môi trường nào khác.
Trên macOS, file watching qua bind mount chậm hơn trên Linux. Nhiều team (kể cả mình) chọn cách lai: chỉ chạy db và redis trong compose (docker compose up db redis), còn next dev chạy trực tiếp trên máy. Cách này nhanh hơn, và localhost:5432 vẫn dùng được nhờ port đã map.
Kubernetes cơ bản: Deployment, Service, ConfigMap, Secret, Ingress
Năm object bạn cần cho một web app:
- Deployment khai báo "luôn có N pod chạy image này" và quản lý rolling update.
- Service cho các pod một địa chỉ ổn định trong cluster (
shop-web.shop.svc.cluster.local) và cân bằng tải giữa chúng. - ConfigMap chứa cấu hình không nhạy cảm, Secret chứa thông tin nhạy cảm.
- Ingress (hoặc Gateway API) đưa traffic HTTP từ ngoài vào Service.
Secret tạo bằng lệnh thay vì commit file YAML:
kubectl create namespace shop
kubectl -n shop create secret generic shop-web-secrets \
--from-literal=DATABASE_URL="$DATABASE_URL" \
--from-literal=SESSION_SECRET="$SESSION_SECRET"
Secret trong Kubernetes chỉ được mã hóa base64, không phải mã hóa thật. Ai có quyền get secrets trong namespace là đọc được. Bảo vệ nó bằng RBAC, bật encryption at rest cho etcd (managed cluster thường có tùy chọn này), và với GitOps thì dùng External Secrets Operator hoặc Sealed Secrets thay vì commit Secret.
Manifest chính:
# k8s/shop-web.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: shop-web-config
namespace: shop
data:
ORDERS_API_URL: http://orders-api.shop.svc.cluster.local:8080
LOG_LEVEL: info
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop-web
namespace: shop
labels:
app.kubernetes.io/name: shop-web
spec:
# Không khai báo replicas khi dùng HPA (xem phần dưới)
revisionHistoryLimit: 5
selector:
matchLabels:
app.kubernetes.io/name: shop-web
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app.kubernetes.io/name: shop-web
spec:
terminationGracePeriodSeconds: 45
securityContext:
runAsNonRoot: true
runAsUser: 1000
containers:
- name: web
image: registry.acme.vn/shop-web:2026.11.03-3f2a1bc
ports:
- name: http
containerPort: 3000
envFrom:
- configMapRef:
name: shop-web-config
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: shop-web-secrets
key: DATABASE_URL
- name: NODE_OPTIONS
value: --max-old-space-size=384
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 512Mi
startupProbe:
httpGet: { path: /api/health, port: http }
periodSeconds: 2
failureThreshold: 30 # tối đa 60 giây để khởi động
readinessProbe:
httpGet: { path: /api/ready, port: http }
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
livenessProbe:
httpGet: { path: /api/health, port: http }
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
lifecycle:
preStop:
sleep:
seconds: 10 # cluster cũ: exec: { command: ['sleep', '10'] }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ['ALL']
volumeMounts:
- name: next-cache
mountPath: /app/.next/cache # Next.js cần ghi cache ISR và ảnh
- name: tmp
mountPath: /tmp
volumes:
- name: next-cache
emptyDir: {}
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: shop-web
namespace: shop
spec:
selector:
app.kubernetes.io/name: shop-web
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-web
namespace: shop
spec:
ingressClassName: traefik # tên IngressClass trong cluster: kubectl get ingressclass
tls:
- hosts: [shop.acme.vn]
secretName: shop-web-tls
rules:
- host: shop.acme.vn
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shop-web
port:
name: http
Vài chi tiết đáng chú ý:
- Tag image bất biến.
shop-web:2026.11.03-3f2a1bc(ngày + commit) thay vìlatest. Vớilatest, bạn không biết pod nào đang chạy code nào,rollout undokhông có ý nghĩa, vàimagePullPolicymặc định thànhAlways. readOnlyRootFilesystem: truechặn kẻ tấn công ghi file thực thi vào container. Cái giá là phải mountemptyDircho các thư mục app cần ghi:.next/cachevới Next.js,/tmpvới nhiều thư viện. Nếu app dùng ISR, cache handler mặc định của Next.js còn ghi kết quả revalidate vào thư mục build trên filesystem, nên hãy theo dõi log lỗi ghi file sau khi bật. Với nhiều replica, mỗi pod có cache filesystem riêng và có thể trả về nội dung lệch nhau, nên tài liệu self-hosting của Next.js khuyên cấu hìnhcacheHandlerdùng chung (ví dụ Redis). Làm vậy thì giải quyết luôn chuyện ghi file.- ConfigMap và Secret qua
envkhông tự cập nhật. Sửa ConfigMap xong, pod đang chạy vẫn giữ giá trị cũ cho tới khi được tạo lại. Chạykubectl -n shop rollout restart deployment/shop-web, hoặc để mỗi lần config đổi là một lần rollout: Helm hay dùng annotation chứa checksum của config trong pod template, Kustomize thì thêm hash vào tên ConfigMap sinh ra. - Ingress controller. Ingress chỉ là khai báo, cần một controller trong cluster thực thi nó. Dự án
ingress-nginxcủa cộng đồng Kubernetes đã thông báo ngừng phát triển (maintenance dừng từ tháng 3/2026, xem bài thông báo trên blog Kubernetes), nên với cluster mới, hãy chọn controller khác hoặc chuyển sang Gateway API. Cùng route đó viết bằng Gateway API:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop-web
namespace: shop
spec:
parentRefs:
- name: public-gateway # Gateway do team platform quản lý
namespace: infra
hostnames: ['shop.acme.vn']
rules:
- backendRefs:
- name: shop-web
port: 80
Gateway API tách vai trò rõ hơn: team platform quản lý Gateway (listener, TLS), team app chỉ khai báo HTTPRoute. Gateway phải cho phép route từ namespace shop (qua allowedRoutes) thì route mới được gắn vào.
Probes, resources và heap của Node
Hai route health cho Next.js:
// lib/db.ts
import pg from 'pg'
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5 })
// app/api/health/route.ts
// Liveness/startup: chỉ trả lời "process còn sống", không đụng dependency.
// Next.js 15+: GET route handler không bị cache mặc định. Next 14 cần thêm:
// export const dynamic = 'force-dynamic'
export function GET() {
return Response.json({ status: 'ok' })
}
// app/api/ready/route.ts
import { setTimeout as delay } from 'node:timers/promises'
import { pool } from '@/lib/db'
export async function GET() {
try {
await Promise.race([
pool.query('select 1'),
delay(1_000).then(() => {
throw new Error('database check timed out')
}),
])
return Response.json({ status: 'ready' })
} catch (error) {
return Response.json(
{ status: 'not-ready', reason: (error as Error).message },
{ status: 503 },
)
}
}
| Probe | Câu hỏi nó trả lời | Khi fail | Nên kiểm tra |
|---|---|---|---|
startupProbe |
App đã khởi động xong chưa? | Quá failureThreshold × periodSeconds thì restart container. Trong lúc chờ, liveness và readiness chưa chạy |
Endpoint nhẹ, giống liveness |
readinessProbe |
Lúc này có nên nhận traffic không? | Pod bị gỡ khỏi endpoint của Service, không restart | Dependency thiết yếu (DB), timeout ngắn |
livenessProbe |
Process có bị treo không thể tự hồi phục? | Restart container | Chỉ process. Không kiểm tra DB hay service khác |
Quy tắc quan trọng nhất: liveness không bao giờ kiểm tra dependency. Nếu Postgres chậm 30 giây và liveness gọi DB, mọi pod cùng fail liveness, cùng bị restart, rồi cùng mở connection mới tới một DB đang quá tải. Sự cố nhỏ thành sự cố toàn hệ thống. Readiness kiểm tra DB thì chấp nhận được vì nó chỉ gỡ traffic, nhưng cũng cân nhắc: nếu DB chết, mọi pod not-ready và người dùng nhận lỗi từ ingress thay vì trang lỗi do app render. Một số team chỉ để readiness kiểm tra chính app.
startupProbe tồn tại để bạn không phải đặt initialDelaySeconds lớn cho liveness. App khởi động chậm (warm cache, migration nhỏ) được tối đa 60 giây như cấu hình trên, sau đó liveness mới bắt đầu với ngưỡng chặt hơn.
Requests và limits. requests là phần scheduler giữ chỗ cho pod trên node, và là gốc để HPA tính phần trăm sử dụng. limits.memory là trần: vượt qua là container bị kernel kill (OOMKilled, exit code 137). Mình đặt memory request bằng memory limit: mức dùng không bao giờ vượt request, nên khi node thiếu RAM, pod này nằm cuối danh sách bị evict (dù về QoS nó vẫn là Burstable nếu không đặt CPU limit). Với CPU, mình thường chỉ đặt request mà không đặt limit: CPU limit làm container bị throttle theo từng chu kỳ ngắn, và với Node (một thread chính) điều đó thể hiện thành latency tăng vọt khó hiểu. Nếu cluster dùng chung và cần công bằng giữa các team, đặt CPU limit cao hơn request khá nhiều.
Heap của V8 và memory limit. Heap mặc định của Node phụ thuộc version và cách nó đọc giới hạn bộ nhớ của container, nên đừng đoán. Kiểm tra trong chính pod:
kubectl -n shop exec deploy/shop-web -- \
node -e "console.log(Math.round(require('v8').getHeapStatistics().heap_size_limit / 1048576) + ' MB')"
# 432 MB (output minh họa; với NODE_OPTIONS=--max-old-space-size=384, con số gồm old space + phần young generation)
Đặt --max-old-space-size khoảng 75% memory limit (384 MB cho limit 512Mi). Phần còn lại dành cho bộ nhớ ngoài heap: Buffer và ArrayBuffer, native module, stack của các thread, code đã compile, cache của chính Next.js. Nếu đặt heap bằng đúng limit, V8 nghĩ nó còn chỗ và không chạy GC mạnh, trong khi tổng RSS đã vượt limit, và kernel kill process trước khi V8 kịp báo "heap out of memory". Đó là lý do pod bị OOMKilled dù metrics heap trông vẫn ổn.
Rolling update không rớt request: maxSurge, preStop và HPA
Với maxSurge: 1, maxUnavailable: 0, Kubernetes tạo thêm một pod mới, đợi nó ready, rồi mới tắt một pod cũ, lặp lại tới hết. Số pod sẵn sàng không bao giờ giảm dưới số mong muốn. Mặc định là 25%/25%, trong đó maxSurge làm tròn lên và maxUnavailable làm tròn xuống, nên với 3 replica mặc định tương đương 1/0, nhưng từ 4 replica trở lên thì mặc định cho phép một pod không sẵn sàng trong lúc deploy. Ghi rõ ra để không phụ thuộc vào số replica.
Phần khó nằm ở lúc tắt pod cũ. Đây là trình tự thực tế:
t=0s Pod chuyển sang Terminating
├─ Control plane gỡ pod khỏi EndpointSlice. kube-proxy và ingress controller
│ nhận cập nhật này BẤT ĐỒNG BỘ, có thể chậm vài giây
└─ Kubelet chạy preStop (sleep 10s): app vẫn phục vụ request bình thường
t=10s preStop xong, kubelet gửi SIGTERM tới PID 1 (tini -> node)
App ngừng nhận connection mới, chờ request đang chạy, đóng DB pool
t≈13s App thoát với mã 0
t=45s Nếu app vẫn chưa thoát: SIGKILL
(terminationGracePeriodSeconds tính từ t=0, đã bao gồm thời gian preStop)
Không có preStop, SIGTERM đến ngay tại t=0, app đóng listener trong khi ingress vẫn còn gửi request tới pod đó thêm vài giây. Đó chính là chùm 502 mỗi lần deploy. Sleep 5-10 giây là đủ cho hầu hết cluster. Các version Kubernetes gần đây có sẵn action sleep cho lifecycle hook (bật mặc định từ 1.30). Với cluster cũ hơn, dùng exec gọi lệnh sleep, và image phải có binary đó (image slim có, distroless thì không).
Ràng buộc cần giữ: terminationGracePeriodSeconds > thời gian preStop + thời gian drain tối đa của app. Với cấu hình trên: 10 giây preStop + 25 giây timeout drain = 35 giây, nhỏ hơn 45 giây, nên app luôn có cơ hội tự thoát. Với job xử lý lâu (export báo cáo, upload file lớn), tăng con số này hoặc đưa việc đó vào queue.
Autoscaling với HPA (cần metrics-server trong cluster):
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: shop-web
namespace: shop
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: shop-web
minReplicas: 3
maxReplicas: 12
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # 70% của CPU request, không phải của node
behavior:
scaleDown:
stabilizationWindowSeconds: 300
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: shop-web
namespace: shop
spec:
minAvailable: 2
selector:
matchLabels:
app.kubernetes.io/name: shop-web
- HPA tính phần trăm theo
requests. Không đặt CPU request thì HPA theo CPU không hoạt động. - Bỏ
replicaskhỏi Deployment khi dùng HPA. Nếu giữ, mỗi lầnkubectl applysẽ đặt lại số replica về giá trị trong file, đè lên quyết định của HPA, rồi HPA lại scale lên, gây dao động. stabilizationWindowSeconds: 300ở chiều scale down giúp HPA không giảm pod ngay khi traffic hạ xuống một chút.PodDisruptionBudgetbảo vệ khi node bị drain (nâng cấp cluster, autoscaler thu hồi node): Kubernetes không evict pod nếu việc đó làm số pod sẵn sàng giảm dưới 2.
CPU là thước đo tốt cho Next.js SSR vì render tốn CPU. Với service chủ yếu chờ I/O (gọi API khác, đọc DB), CPU thấp ngay cả khi đã quá tải, nên cân nhắc scale theo số request đang xử lý hoặc độ dài queue qua custom metrics (KEDA là lựa chọn phổ biến).
Debug bằng kubectl và cluster local với kind hoặc k3d
Khi có sự cố, mình đi theo thứ tự này:
# 1. Pod đang ở trạng thái nào
kubectl -n shop get pods -l app.kubernetes.io/name=shop-web -o wide
# 2. Vì sao: Events ở cuối, Last State cho biết lần restart trước chết vì gì
kubectl -n shop describe pod shop-web-7d9c6b5f4-x2k8p
# Last State: Terminated (output minh họa)
# Reason: OOMKilled
# Exit Code: 137
# Warning Unhealthy Readiness probe failed: HTTP probe failed with statuscode: 503
# 3. Log, kể cả log của container trước khi bị restart
kubectl -n shop logs deploy/shop-web --since=15m
kubectl -n shop logs shop-web-7d9c6b5f4-x2k8p --previous
# 4. Sự kiện toàn namespace (lỗi schedule, pull image, probe)
kubectl -n shop get events --sort-by=.lastTimestamp | tail -20
# 5. Vào trong container, hoặc gắn container debug khi image không có shell
kubectl -n shop exec -it shop-web-7d9c6b5f4-x2k8p -- sh
kubectl -n shop debug -it shop-web-7d9c6b5f4-x2k8p --image=busybox:1.36 --target=web
# 6. Gọi thẳng service từ máy mình, bỏ qua ingress
kubectl -n shop port-forward svc/shop-web 3000:80
curl -i http://localhost:3000/api/ready
# 7. Rollout
kubectl -n shop rollout status deploy/shop-web
kubectl -n shop rollout history deploy/shop-web
kubectl -n shop rollout undo deploy/shop-web
kubectl -n shop top pods # cần metrics-server
Bảng tra nhanh các trạng thái hay gặp:
| Trạng thái | Nguyên nhân thường gặp | Xem ở đâu |
|---|---|---|
Pending |
Không node nào đủ CPU/memory cho requests, hoặc PVC chưa bind |
describe pod phần Events (Insufficient cpu) |
ImagePullBackOff |
Sai tên/tag image, thiếu imagePullSecrets, registry private |
describe pod |
CreateContainerConfigError |
Secret hoặc ConfigMap được tham chiếu không tồn tại, sai key | describe pod |
CrashLoopBackOff |
App crash khi khởi động: thiếu env, không kết nối được DB, lỗi code | logs --previous |
OOMKilled (exit 137) |
Vượt memory limit, heap V8 đặt quá sát limit | describe pod phần Last State |
Running nhưng 0/1 READY |
Readiness probe fail | Events + gọi thử endpoint qua port-forward |
Cluster local. Không cần cloud để tập: kind chạy các node Kubernetes dưới dạng container Docker, k3d chạy k3s (bản Kubernetes gọn nhẹ, có sẵn Traefik làm ingress controller) trong Docker.
# kind
kind create cluster --name shop
docker build -t shop-web:dev .
kind load docker-image shop-web:dev --name shop # đưa image vào node, không cần registry
# hoặc k3d, map cổng 8080 máy mình vào load balancer của cluster
k3d cluster create shop -p "8080:80@loadbalancer"
k3d image import shop-web:dev -c shop
kubectl apply -f k8s/
Bẫy hay gặp: dùng tag latest cho image local thì imagePullPolicy mặc định là Always, Kubernetes đi kéo image từ Docker Hub và báo ImagePullBackOff. Dùng tag khác latest (như dev) hoặc đặt imagePullPolicy: IfNotPresent.
Khi nào Kubernetes là quá tay
Kubernetes giải quyết bài toán của tổ chức có nhiều service, nhiều team và có người chuyên vận hành nền tảng. Nó không miễn phí: nâng cấp cluster định kỳ, quản lý ingress/gateway, certificate, RBAC, observability, chi phí control plane và node dư. Với một app Next.js và một hai service, phần lớn thời gian sẽ dành cho việc vận hành cluster thay vì sản phẩm.
| Lựa chọn | Phù hợp khi | Bạn tự lo | Cách scale | Hạn chế chính |
|---|---|---|---|---|
| Kubernetes (EKS, GKE, AKS hoặc tự dựng) | Nhiều service, cần job/cronjob, sidecar, network policy, có người vận hành | Cluster, ingress, upgrade, RBAC, monitoring | HPA + cluster autoscaler | Chi phí vận hành và kiến thức cao nhất |
| Google Cloud Run | Service HTTP stateless, traffic lên xuống thất thường | Image và cấu hình service | Theo số request đồng thời, scale về 0 được | Mặc định CPU chỉ được cấp khi đang xử lý request, không hợp cho việc chạy nền liên tục |
| Fly.io | App cần chạy gần người dùng ở nhiều region | fly.toml, Machines |
Thêm Machines, tự dừng/khởi động theo traffic | Mô hình riêng của nền tảng, cần học thêm |
| Railway | Team nhỏ, muốn deploy từ repo hoặc Dockerfile, cần Postgres/Redis đi kèm | Gần như chỉ app | Cấu hình số replica | Ít quyền kiểm soát network và hạ tầng |
| VPS + Docker Compose | Một app, traffic ổn định, chấp nhận vài giây gián đoạn khi deploy | Toàn bộ máy chủ, backup, bảo mật OS | Nâng cấu hình máy | Một máy chết là cả hệ thống chết |
Quy tắc của mình: chưa có ít nhất một người sẵn sàng chịu trách nhiệm cho cluster, hoặc số service đếm được trên một bàn tay, thì chọn Cloud Run, Fly.io hay Railway. Mọi thứ ở phần Docker của bài này (image nhỏ, non-root, xử lý SIGTERM, health endpoint) vẫn áp dụng nguyên vẹn trên các nền tảng đó, và khi một ngày cần chuyển sang Kubernetes, image của bạn đã sẵn sàng.
Những lỗi thường gặp
1. Shell form trong CMD
# Sai: /bin/sh là PID 1, SIGTERM không tới được Node
CMD npm start
# Đúng: exec form, qua tini
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "server.js"]
2. Copy source trước khi cài dependency
# Sai: sửa một dòng code là cài lại toàn bộ dependency
COPY . .
RUN npm ci
# Đúng
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
3. Liveness probe gọi database
# Sai: DB chậm làm mọi pod bị restart cùng lúc
livenessProbe:
httpGet: { path: /api/ready, port: http }
# Đúng: liveness chỉ kiểm tra process
livenessProbe:
httpGet: { path: /api/health, port: http }
4. Heap bằng memory limit
# Sai: RSS vượt limit trước khi V8 kịp GC, pod bị OOMKilled
- name: NODE_OPTIONS
value: --max-old-space-size=512 # limit 512Mi
# Đúng: chừa khoảng 25% cho bộ nhớ ngoài heap
- name: NODE_OPTIONS
value: --max-old-space-size=384
5. Không có preStop. Pod nhận SIGTERM trong khi ingress vẫn gửi traffic tới, tạo ra lỗi 502 mỗi lần deploy. Thêm preStop sleep 5-10 giây và kiểm tra terminationGracePeriodSeconds đủ lớn.
6. Quên copy public và .next/static khi dùng standalone. Trang HTML trả về bình thường nhưng toàn bộ asset 404.
7. depends_on không có condition trong compose: app khởi động trước khi Postgres sẵn sàng và crash ngay lần đầu.
8. Giữ replicas trong Deployment khi đã có HPA. Mỗi lần kubectl apply reset số pod, đè lên quyết định của HPA.
Checklist
- Bật
output: 'standalone', thêm.dockerignore, và kiểm tra image runtime không chứanode_modulesđầy đủ hay source. - Đổi
CMDsang exec form, thêmtini, chạy bằng user không phải root. - Thêm handler SIGTERM:
server.close(),closeIdleConnections(), đóng DB pool, timer ép thoát; kiểm tra bằngtime docker stop. - So sánh image
alpinevàslimvới dependency thật của bạn; đo bằngdocker historyhoặcdive. - Trong
compose.yaml, đổi mọidepends_onsangcondition: service_healthyvà bind port DB vào127.0.0.1. - Tách endpoint
/api/health(liveness, startup) và/api/ready(readiness). - Đặt memory request bằng limit,
--max-old-space-sizekhoảng 75% limit, và xác nhận bằngv8.getHeapStatistics()trong pod. - Thêm
preStopsleep,maxUnavailable: 0,terminationGracePeriodSecondslớn hơn preStop + thời gian drain. - Dùng tag image bất biến (ngày + commit SHA), không dùng
latest. - Trước khi dựng cluster, trả lời được: ai sẽ vận hành nó, và Cloud Run/Fly.io/Railway thiếu gì mà bạn thực sự cần.
Đọc thêm
- Next.js: output standalone
- Kubernetes: Configure Liveness, Readiness and Startup Probes
- Kubernetes: Pod Lifecycle (termination of Pods)
- Kubernetes blog: Ingress NGINX Retirement
- Docker Docs: Compose file reference
- CI/CD với GitHub Actions cho dự án Next.js
- Tối ưu Node.js trong production: đo đúng chỗ trước khi tối ưu
- Logging và observability cho Node.js