jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Node.js Production Engineering 07 — Đưa Service lên Production

Biến source code thành một service vận hành được: image bất biến, config và secret, probe, graceful shutdown, migration, rollout, rollback, CI/CD và quan sát deployment.

Deploy thành công không có nghĩa process đã khởi động. Một release chỉ thành công khi phiên bản mới nhận traffic đúng, không làm mất request đang chạy, tương thích với dữ liệu hiện tại, phát ra tín hiệu quan sát được và có đường rollback đã kiểm chứng.

Production vì thế là một contract giữa application và platform:

application hứa: artifact xác định, probe đúng nghĩa, shutdown có giới hạn,
                 migration tương thích, telemetry đủ để quyết định

platform hứa:    config/secret đúng lúc, resource limit, routing, restart,
                 rollout có kiểm soát, lưu log/metric/trace

Thiếu một vế, lỗi dễ bị quy cho “Docker”, “Kubernetes” hoặc “network” dù nguyên nhân thật nằm ở lifecycle chưa được thiết kế.

Sau bài này, bạn sẽ làm được gì?

  • Tạo một image Node.js nhỏ, bất biến, non-root và tái lập được.
  • Phân biệt config với secret; validate trước khi nhận traffic.
  • Thiết kế startup, liveness và readiness probe không gây cascading failure.
  • Drain HTTP, database pool và worker trong một shutdown budget rõ ràng.
  • Tách build, release và runtime; triển khai migration theo expand–contract.
  • Chọn rolling, blue-green hoặc canary theo rủi ro thay đổi.
  • Dùng deployment metric và SLO để tự động dừng hoặc rollback release.

Mental model: build một lần, promote cùng artifact

Pipeline production có ba pha khác nhau:

BUILD
source + lockfile ─▶ test ─▶ image@sha256 ─▶ SBOM/provenance

RELEASE
image digest + config version + migration plan ─▶ staging ─▶ production

RUNTIME
replica + resource limits + probes + telemetry + shutdown contract

Không rebuild cho từng môi trường. Nếu staging chạy image A nhưng production được build lại thành image B, staging không còn là bằng chứng cho production. Hãy promote cùng digest, chỉ thay config được quản trị bên ngoài artifact.

Config và secret: fail trước traffic

Config là giá trị thay đổi giữa môi trường. Secret là config cần cơ chế lưu trữ, phân quyền, audit và rotation riêng.

import { z } from 'zod';

const RuntimeConfig = z.object({
  NODE_ENV: z.literal('production'),
  PORT: z.coerce.number().int().min(1).max(65_535).default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().max(30_000),
  RELEASE_SHA: z.string().min(7),
});

export type RuntimeConfig = z.infer<typeof RuntimeConfig>;
export const config = RuntimeConfig.parse(process.env);

Nguyên tắc vận hành:

  • Validate một lần ở startup và dừng với lỗi rõ ràng nếu sai.
  • Không log toàn bộ process.env; redaction sau khi log đã rời process là quá muộn.
  • Secret được inject ở runtime từ secret manager, không bake vào image hoặc commit trong .env.
  • Rotation cần hỗ trợ giai đoạn hai credential cùng hợp lệ; đổi secret một bước có thể tự khóa service.
  • /config debug endpoint, nếu có, chỉ hiển thị giá trị không nhạy cảm và phải được bảo vệ.

Node hỗ trợ --env-file cho local workflow, nhưng production vẫn cần secret manager và audit thay vì chuyển file bí mật quanh hệ thống.


Dockerfile: tối ưu tính xác định trước kích thước

Multi-stage build tách compiler/dev dependency khỏi runtime. Ví dụ dưới dùng cùng họ base image giữa build và runtime để giảm rủi ro native module không tương thích:

# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM dependencies AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build \
 && npm prune --omit=dev

FROM node:24-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app

COPY --from=build --chown=node:node /app/package.json ./package.json
COPY --from=build --chown=node:node /app/package-lock.json ./package-lock.json
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Thêm .dockerignore để credential, .git, test output và local node_modules không vào build context.

.git
.env*
node_modules
coverage
dist
npm-debug.log*

Các quyết định cần ghi rõ

Quyết địnhLợi íchChi phí/rủi ro
Pin base image bằng digestBuild tái lập, audit rõPhải có bot/quy trình cập nhật security patch
Image slimÍt package và CVE hơnDebug tại chỗ khó hơn
Distroless/hardenedBề mặt tấn công nhỏKhông có shell; native dependency cần kiểm tra kỹ
Chạy non-rootGiảm quyền khi bị khai thácPhải chuẩn hóa quyền file/volume
Một process/containerLifecycle và resource rõScale qua replica, không qua PM2 trong container

Kích thước nhỏ không tự động đồng nghĩa an toàn. Một image 50 MB với dependency lỗi thời vẫn rủi ro hơn image lớn hơn nhưng được cập nhật, ký và quét đều đặn.

Supply-chain gate

Pipeline nên tạo và lưu:

  • image digest thay vì chỉ tag latest;
  • SBOM cho dependency OS và Node;
  • provenance/attestation cho biết ai build từ commit nào;
  • kết quả scan có policy severity và exception có hạn sử dụng;
  • chữ ký hoặc cơ chế admission chỉ cho artifact được tin cậy.

Tag thuận tiện cho con người; digest mới là identity bất biến của artifact.


Probe phải trả lời một câu hỏi duy nhất

Ba probe có mục đích khác nhau:

ProbeCâu hỏiHành động khi fail
StartupApp đã khởi động xong chưa?Cho thêm thời gian hoặc restart nếu vượt ngưỡng
LivenessProcess còn có khả năng tiến triển không?Restart container
ReadinessReplica này có nên nhận traffic mới không?Bỏ khỏi load balancer, không nhất thiết restart

Liveness không nên gọi database. Nếu database chung bị lỗi, mọi replica cùng fail liveness và bị restart liên tục, làm sự cố nặng hơn. Readiness có thể xét dependency bắt buộc, nhưng phải có timeout ngắn và không biến probe thành load generator.

let shuttingDown = false;

app.get('/livez', (_req, res) => {
  res.status(200).json({ status: 'alive' });
});

app.get('/readyz', async (_req, res) => {
  if (shuttingDown) {
    return res.status(503).json({ status: 'draining' });
  }

  try {
    await Promise.race([
      pool.query('SELECT 1'),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('readiness timeout')), 150)
      ),
    ]);
    return res.status(200).json({ status: 'ready' });
  } catch {
    return res.status(503).json({ status: 'not_ready' });
  }
});

Nếu service có degraded mode hợp lệ — ví dụ Redis down nhưng có thể đọc database — Redis không nên làm readiness fail. Đưa dependency vào readiness theo khả năng phục vụ contract, không theo danh sách kết nối.

Kubernetes dùng startup probe để trì hoãn liveness/readiness cho app khởi động chậm:

startupProbe:
  httpGet: { path: /livez, port: 3000 }
  periodSeconds: 2
  failureThreshold: 30

livenessProbe:
  httpGet: { path: /livez, port: 3000 }
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet: { path: /readyz, port: 3000 }
  periodSeconds: 5
  timeoutSeconds: 1
  failureThreshold: 2

Graceful shutdown là một deadline

Khi platform thay replica, process nhận signal và có một khoảng grace period. Trình tự cần xác định:

SIGTERM
  1. readiness=false, ngừng traffic mới
  2. ngừng nhận job/consumer message mới
  3. đóng HTTP listener, chờ request đang chạy
  4. flush telemetry cần thiết
  5. đóng DB/Redis/broker connection
  6. exit 0 trước deadline; quá hạn thì exit 1 có log
import type { Server } from 'node:http';

export function installShutdown(server: Server) {
  let started = false;

  async function shutdown(signal: string) {
    if (started) return;
    started = true;
    shuttingDown = true;

    logger.info({ signal }, 'shutdown started');

    const deadline = AbortSignal.timeout(25_000);
    const forceExit = () => {
      logger.error('shutdown deadline exceeded');
      process.exit(1);
    };
    deadline.addEventListener('abort', forceExit, { once: true });

    try {
      await emailWorker.close();
      await new Promise<void>((resolve, reject) => {
        server.close((error) => (error ? reject(error) : resolve()));
      });
      await Promise.allSettled([
        pool.end(),
        redis.quit(),
        telemetry.shutdown(),
      ]);

      deadline.removeEventListener('abort', forceExit);
      logger.info('shutdown completed');
      process.exit(0);
    } catch (error) {
      logger.error({ error }, 'shutdown failed');
      process.exit(1);
    }
  }

  process.once('SIGTERM', () => void shutdown('SIGTERM'));
  process.once('SIGINT', () => void shutdown('SIGINT'));
}

Shutdown handler phải idempotent vì nhiều signal có thể đến. Grace period của platform phải lớn hơn deadline nội bộ. Với request streaming/WebSocket, cần policy riêng: gửi close signal, đặt max connection age hoặc chuyển client sang replica khác.

Acceptance signal: metric shutdown_duration_seconds, số request bị ngắt và số job bị trả lại queue. Chỉ log “received SIGTERM” chưa chứng minh drain thành công.


Resource request, limit và backpressure

Container không có tài nguyên vô hạn. Định nghĩa resource dựa trên profile dưới tải:

resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    cpu: '1'
    memory: 512Mi
  • CPU limit quá thấp làm event-loop latency tăng dù host còn CPU.
  • Memory limit quá sát RSS bình thường gây OOM kill trước khi heap alert kịp phản ứng.
  • Pool DB, HTTP agent và worker concurrency phải tính theo toàn bộ replica, không theo một process.

Ví dụ 20 replica × pool 20 = 400 connection. Nếu Postgres chỉ chịu 200, autoscale API có thể làm database sập. HPA cũng cần metric phản ánh saturation; scale theo CPU không giúp khi bottleneck là DB connection hoặc downstream quota.

Backpressure phải xuất hiện trước giới hạn cứng: queue hữu hạn, concurrency cap, 429/503Retry-After, và load shedding cho request ít quan trọng.


Migration: expand–migrate–contract

Code cũ và code mới thường chạy đồng thời trong rolling deployment. Migration phá tương thích là nguồn lỗi phổ biến nhất.

Ví dụ đổi users.name thành display_name:

EXPAND   thêm display_name nullable; code mới đọc fallback name
MIGRATE  backfill theo batch; đo remaining rows và replication lag
SWITCH   code mới ghi/read display_name; vẫn giữ compatibility
CONTRACT xóa name ở release sau khi không còn reader cũ

Không gộp drop column và deploy code mới trong một bước. Rollback code không rollback được dữ liệu đã bị xóa.

Migration production cần:

  • lock/statement timeout;
  • ước lượng số dòng, tốc độ backfill và ảnh hưởng I/O;
  • khả năng resume theo checkpoint;
  • dashboard tiến độ và error;
  • runbook pause/rollback;
  • owner và cửa sổ thực thi.

Schema migration là một release artifact độc lập, không phải side effect bí mật khi application boot. Nhiều replica cùng chạy migration khi startup có thể tranh lock và làm outage.


Chọn chiến lược rollout

Chiến lượcHợp khiCần chuẩn bị
RollingThay đổi thường, backward-compatibleProbe đúng, đủ capacity khi một số replica unavailable
Blue-greenMuốn switch/rollback nhanh toàn phiên bảnHai environment, data/schema vẫn phải tương thích
CanaryRủi ro cao, có metric phân biệt cohortTraffic splitting, sample đủ lớn, auto-analysis

Canary không có metric và threshold chỉ là rolling deployment chậm. Trước rollout, định nghĩa guardrail:

5xx rate       không tăng quá 0,5 điểm % so với baseline
p99 latency    không tăng quá 20%
readiness fail = 0 sau warm-up
event-loop lag p99 < 50 ms
business KPI   checkout success không giảm quá 1%

So sánh canary với control cùng thời gian để tránh kết luận sai vì traffic theo giờ. Tự động pause khi guardrail fail; rollback chỉ an toàn nếu schema, event và cache key vẫn tương thích.


CI/CD là chuỗi bằng chứng

Một pipeline tối thiểu:

name: delivery
on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write
  id-token: write

concurrency:
  group: production
  cancel-in-progress: false

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

  deploy:
    needs: verify
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy previously built image digest
        run: ./scripts/deploy-image.sh "$IMAGE_DIGEST"

Trong hệ thống thật, job build image xuất digest/SBOM/provenance thành artifact; job deploy chỉ nhận digest đó. GitHub Environment có thể giới hạn branch, secret và approval. Ưu tiên OIDC với cloud provider thay cho access key sống lâu.

Các gate nên phản ánh rủi ro:

  • unit/lint/typecheck chạy nhanh trên mọi PR;
  • integration và migration compatibility trước merge;
  • image scan, policy và signature trước registry promotion;
  • smoke test sau deploy;
  • canary analysis trước mở 100% traffic;
  • synthetic check và rollback drill định kỳ.

Observability cho deployment

Mọi log/metric/trace cần có service.version, deployment.environment và release SHA. Nếu không, dashboard không phân biệt lỗi của bản mới với nhiễu nền.

Dashboard release nên trả lời:

  1. Replica mới có ready ổn định không?
  2. Error, latency và saturation đổi thế nào so với control?
  3. Dependency nào xuất hiện retry/timeout mới?
  4. Business flow chính có suy giảm không?
  5. Rollback mất bao lâu và có để lại data incompatibility không?

Theo dõi cả delivery performance: deployment frequency, lead time, change failure rate và time to restore. Chỉ tối ưu số lần deploy mà bỏ change failure rate sẽ tạo tốc độ giả.

Failure modes và runbook

Triệu chứngKhả năngHành động đầu tiên
Replica restart vòng lặpLiveness quá nhạy, config sai, OOMXem exit reason/event; dừng rollout
Ready nhưng 5xx caoProbe quá nông, dependency/config lỗiGiảm traffic canary; so trace theo version
Deploy treo ở terminatingConnection/job không drainKiểm active handles, shutdown duration
DB latency tăng khi deployMigration lock/backfill quá mạnhPause migration, kiểm lock/replication
Chỉ bản mới lỗiContract/event/cache không tương thíchRollback image nếu data vẫn compatible
Toàn hệ thống chậm khi scalePool/quota tính sai theo replicaGiảm concurrency/replica, bảo vệ dependency

Runbook phải chứa lệnh và owner cụ thể, không chỉ “kiểm tra log”. Thực hành rollback trong giờ bình thường; outage không phải lúc đầu tiên thử quy trình.

Checklist trước khi mở traffic

  • Image được build một lần, pin bằng digest và chạy non-root.
  • Lockfile, SBOM, provenance và scan result được lưu cùng release.
  • Config được validate; secret không nằm trong image/log.
  • Startup/liveness/readiness probe có semantics và timeout đúng.
  • Graceful shutdown đã được test với request/job đang chạy.
  • Resource request/limit và pool size được tính theo tổng replica.
  • Migration theo expand–contract, có lock timeout và kế hoạch resume.
  • Rollout có guardrail, auto-pause và rollback path.
  • Telemetry gắn release SHA; dashboard so sánh canary/control.
  • Runbook và owner trực ca đã rõ.

Lab: phát hành Ordering Service không mất request

Yêu cầu

  1. Viết multi-stage Dockerfile, .dockerignore, chạy non-root.
  2. Thêm /livez, /readyz, startup probe và readiness semantics khi Redis down.
  3. Cài graceful shutdown cho HTTP, BullMQ, Postgres và telemetry.
  4. Tạo migration expand–contract cho một cột được đổi tên.
  5. Pipeline build image, xuất digest/SBOM, deploy canary và kiểm guardrail.
  6. Gắn release SHA vào log, metric và trace.

Acceptance criteria

  • Cùng một image digest chạy ở staging và production.
  • Secret scan không tìm thấy credential trong layer hoặc build log.
  • Gửi SIGTERM giữa request 10 giây: request hoàn tất, replica ngừng nhận traffic mới và thoát trước 25 giây.
  • Database down làm readiness fail nhưng liveness vẫn pass; không có restart storm.
  • Redis down vẫn ready nếu contract cho phép fallback; metric degraded mode tăng.
  • Code cũ và mới cùng chạy được trong mọi bước migration.
  • Canary tự pause khi 5xx hoặc p99 vượt threshold; rollback phục hồi trong mục tiêu RTO.

Tài liệu chính thức


Phần tiếp theo

Service đã deploy lặp lại được và có guardrail vận hành. Phần 8 dùng chính telemetry và môi trường tải đó để trả lời câu hỏi khó hơn: latency đến từ đâu, capacity thật là bao nhiêu và thay đổi nào cải thiện hiệu năng mà không làm tính đúng đắn hoặc chi phí xấu đi.