BlogBlog · docker

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.

Part of Deep Dive series
May 25, 2026
30 min read
By Leo Pham
dockerkubernetesnodejsdevopsnextjs
Share this post:

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 deps chỉ copy package.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ần node_modules đầy đủ. Service Node thường thì dùng npm ci --omit=dev ở stage riêng.
  • Chạy bằng CMD ["node", "server.js"] (exec form) qua tini, và xử lý SIGTERM: ngừng nhận connection, chờ request xong, đóng DB pool.
  • Kubernetes: startupProbe cho lúc khởi động, readinessProbe quyết định nhận traffic, livenessProbe chỉ kiểm tra process, không kiểm tra DB.
  • --max-old-space-size khoảng 75% memory limit; preStop sleep 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ự COPY quyế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ồi npm ci, sau đó mới COPY . ., 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=cache giữ 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-to với registry) để có lợi ích tương tự.
  • Stage deps dùng npm ci đầy đủ (cả devDependencies) vì next build cần TypeScript, PostCSS, Tailwind... Runtime không cần những thứ đó, và nhờ standalone nên stage runner không copy node_modules từ deps.
  • HOSTNAME=0.0.0.0: server.js bind theo biến HOSTNAME. 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ới 127.0.0.1 (như lệnh HEALTHCHECK bên dưới) sẽ bị từ chối.
  • User node có 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:node cần thiết cho .next, vì Next.js ghi cache ISR và ảnh tối ưu vào .next/cache lúc chạy.
  • NEXT_PUBLIC_* là build-time. Một image build với NEXT_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 (đọc process.env trong 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:

  1. 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à sh khô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ý do docker stop luôn mất đúng 10 giây.
  2. npm start là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ẳng node.
  3. 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ần closeIdleConnections() (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.js củ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_on không có condition chỉ đợ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òn service_completed_successfully đợi service migrate chạy xong và thoát với mã 0.
  • Anonymous volume /app/node_modules. Bind mount ./:/app sẽ che mất node_modules có trong image. Khai báo thêm volume ẩn danh tại /app/node_modules giữ lại bản của image, vốn được cài cho Linux. Không làm vậy thì container sẽ dùng node_modules từ macOS và native module crash. Cái giá: khi bạn thêm package, volume vẫn giữ bản cũ. Chạy docker 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ải localhost:5432. localhost bên trong container là chính container đó.
  • Volume có tên (pgdata) giữ dữ liệu qua các lần docker 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ới latest, bạn không biết pod nào đang chạy code nào, rollout undo không có ý nghĩa, và imagePullPolicy mặc định thành Always.
  • readOnlyRootFilesystem: true chặn kẻ tấn công ghi file thực thi vào container. Cái giá là phải mount emptyDir cho các thư mục app cần ghi: .next/cache với Next.js, /tmp vớ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ình cacheHandler dùng chung (ví dụ Redis). Làm vậy thì giải quyết luôn chuyện ghi file.
  • ConfigMap và Secret qua env khô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ạy kubectl -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-nginx củ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ỏ replicas khỏi Deployment khi dùng HPA. Nếu giữ, mỗi lần kubectl apply sẽ đặ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.
  • PodDisruptionBudget bả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ứa node_modules đầy đủ hay source.
  • Đổi CMD sang exec form, thêm tini, 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ằng time docker stop.
  • So sánh image alpine và slim với dependency thật của bạn; đo bằng docker history hoặc dive.
  • Trong compose.yaml, đổi mọi depends_on sang condition: service_healthy và bind port DB vào 127.0.0.1.
  • Tách endpoint /api/health (liveness, startup) và /api/ready (readiness).
  • Đặt memory request bằng limit, --max-old-space-size khoảng 75% limit, và xác nhận bằng v8.getHeapStatistics() trong pod.
  • Thêm preStop sleep, maxUnavailable: 0, terminationGracePeriodSeconds lớ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

Last updated: May 25, 2026

Comments