Kodokon kodokon.com

การขึ้นสู่ระบบจริง: ตัวจัดการโปรเซส Docker และ cluster

ส่งขึ้นระบบจริง ด้วย NODE_ENV ตัวจัดการโปรเซส อิมเมจ Docker ที่ถูกต้อง และการขยายบนหลายคอร์ด้วย cluster

10 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

NODE_ENV เป็นเพียง ธรรมเนียมของไลบรารี เท่านั้น แกนหลักของ Node แทบจะเพิกเฉยต่อมันโดยสิ้นเชิง แต่ Express และตัวอื่น ๆ ใช้มันเพื่อแคชวิว (view) และซ่อน stack trace บางเฟรมเวิร์กเร็วขึ้นหลายเท่าเมื่อตั้ง NODE_ENV=production ในด้านของ dependency นั้น npm ci --omit=dev จะติดตั้งตาม lockfile อย่างเป๊ะ ๆ โดยไม่มี dev dependency ทำซ้ำได้และเร็วกว่า npm install ตั้งแต่ Node 20.6 เป็นต้นมา --env-file โหลดไฟล์ .env ได้แบบเนทีฟ โดยไม่ต้องพึ่ง dependency

BASH
NODE_ENV=production node server.js

npm ci --omit=dev

node --env-file=.env server.js
สามคำสั่งพื้นฐานสำหรับการขึ้นสู่ระบบจริง

โปรเซส Node จะจบลงด้วยการตายเสมอ ไม่ว่าจะเป็นหน่วยความจำล้น บั๊ก หรือ exception บทบาทของ ตัวจัดการโปรเซส (process manager) คือการรีสตาร์ตมัน มีอยู่สองสำนัก บนเครื่องเปล่า pm2 (หรือ systemd) จะดูแล รีสตาร์ต และรีโหลดโดยไม่มีช่วงหยุดทำงาน ส่วนในคอนเทนเนอร์ ตัวจัดการ (Kubernetes, ECS) จะรับบทนี้ คือหนึ่งโปรเซสต่อหนึ่งคอนเทนเนอร์ และ pm2 จะกลายเป็นสิ่งไร้ประโยชน์ ถึงขั้นเป็นโทษ เพราะมันซ่อนการแครชจากตัวจัดการ

BASH
npm install -g pm2
pm2 start server.js -i max --name api
pm2 reload api
pm2 logs api --lines 100
pm2: -i max แยก worker หนึ่งตัวต่อหนึ่งคอร์ รีโหลดได้โดยไม่มีช่วงหยุดทำงาน

กับดัก Docker สองอย่างที่เฉพาะเจาะจงกับ Node ข้อหนึ่ง อิมเมจควรรันในฐานะผู้ใช้ node ที่ไม่มีสิทธิพิเศษ ซึ่งอิมเมจทางการมีให้ ข้อสอง Node ไม่ได้ถูกออกแบบมาให้เป็น PID 1 มันไม่เก็บกวาดโปรเซสซอมบี้ จงเปิดคอนเทนเนอร์ด้วย docker run --init (หรือ init: true ใน Compose) เพื่อแทรก init ขั้นต่ำเข้าไป สุดท้าย จงเขียน CMD ในรูปแบบ exec form เพราะรูปแบบ shell form จะแทรก /bin/sh เข้ามา ซึ่งไม่ส่งต่อ SIGTERM ทำให้การปิดอย่างนุ่มนวลจากบทเรียนที่แล้วจะไม่มีวันถูกกระตุ้น

BASH
FROM node:20-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:20-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Dockerfile แบบหลายสเตจ: ผู้ใช้ node, CMD ในรูปแบบ exec form

โปรเซส Node เดียวใช้เพียงคอร์เดียว โมดูล cluster สร้างโปรเซส worker หนึ่งตัวต่อหนึ่งคอร์ โดยตัวหลัก (primary) จะฟังที่พอร์ตและกระจายการเชื่อมต่อไปยัง worker แบบ round-robin (พฤติกรรมเริ่มต้น ยกเว้นบน Windows) แต่ละ worker มีหน่วยความจำและอีเวนต์ลูปเป็นของตัวเอง คือ ไม่มีสถานะที่แชร์กัน เซสชันและแคชจึงต้องอยู่ใน Redis หรือสิ่งที่เทียบเท่า สำหรับการคำนวณล้วน ๆ ให้เลือกใช้ worker_threads ซึ่งแชร์หน่วยความจำผ่าน SharedArrayBuffer

JAVASCRIPT
import cluster from "node:cluster";
import { createServer } from "node:http";
import { availableParallelism } from "node:os";

if (cluster.isPrimary) {
  const count = availableParallelism();
  for (let i = 0; i < count; i += 1) cluster.fork();
  cluster.on("exit", (worker) => {
    console.log(`worker ${worker.process.pid} down`);
    cluster.fork();
  });
} else {
  createServer((req, res) => {
    res.end(`pid ${process.pid}`);
  }).listen(3000);
}
หนึ่ง worker ต่อหนึ่งคอร์ รีสตาร์ตอัตโนมัติหากมันตาย

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. จริง ๆ แล้ว NODE_ENV=production เปลี่ยนอะไร?
    • Node.js เปิดใช้คอมไพเลอร์ JIT ที่ดุดันขึ้น
    • ไลบรารีอย่าง Express เปิดใช้การเพิ่มประสิทธิภาพของมัน (การแคชวิว ข้อผิดพลาดแบบไม่ละเอียด)
    • Node.js ปฏิเสธที่จะเริ่มทำงานหากไม่มี HTTPS
  2. เหตุใดจึงควรเลือกใช้รูปแบบ exec form CMD ["node", "server.js"] ใน Dockerfile?
    • รูปแบบ exec form เริ่มทำงานเร็วกว่า
    • รูปแบบ exec form อนุญาตให้แทรกค่าตัวแปรได้
    • รูปแบบ shell form ผ่าน /bin/sh ซึ่งไม่ส่งต่อ SIGTERM ไปยังโปรเซส Node
  3. เมื่อใช้ cluster กับ worker 8 ตัว จะเกิดอะไรขึ้นกับหน่วยความจำและสถานะของแอปพลิเคชัน?
    • worker แต่ละตัวเป็นโปรเซสแยกกัน หน่วยความจำคูณด้วย 8 ไม่มีสถานะที่แชร์กัน
    • worker ทุกตัวแชร์ V8 heap เดียวกัน
    • ตัวหลักรวม heap ของ worker เข้าด้วยกันเป็นระยะ ๆ