Skip to content

บทที่ 7 — Config, CLI + Lifecycle: ตั้งค่าให้ถูก ปิดให้นุ่มนวล

← บทที่ 6: Error Handling + Debugging | สารบัญ | บทที่ 8: Testing →

3 เรื่องที่แยก "สคริปต์เล่น ๆ" ออกจาก "แอป production":

  1. Config ผ่าน environment — ทำไมห้ามฮาร์ดโค้ดรหัส DB ลงไฟล์ (หลัก 12-factor) + .env + validate ตอน startup
  2. CLI tool — รับ argument/flag อย่างเป็นระบบ (ไม่ใช่อ่าน argv ดิบ ๆ)
  3. Graceful shutdown — ปิด server โดยไม่ทิ้ง request ที่ค้างกลางคัน

1. ทำไมต้องเอา config ออกจากโค้ด (12-factor)

นี่คือ anti-pattern ที่มือใหม่ทำกันหมด:

javascript
// ❌ อย่าทำ: ฮาร์ดโค้ด config ลงโค้ด
const db = connect("postgres://admin:SuperSecret123@prod-db:5432/app");
const PORT = 3000;

ปัญหา: (1) รหัสผ่านโผล่ใน git ใครเห็น repo ก็เห็นรหัส (2) เปลี่ยน config ต้องแก้โค้ด + deploy ใหม่ (3) dev/staging/production ใช้ค่าต่างกันไม่ได้ (โค้ดเดียวกันต้องรันได้ทุกที่)

12-Factor App คือแนวทาง 12 ข้อสำหรับออกแบบแอปให้ deploy ง่าย เขียนโดยทีม Heroku — หนึ่งในนั้นบอกว่า: เก็บ config ที่เปลี่ยนตาม environment ไว้ใน environment variable (บทที่ 2 — process.env)

หลักสำคัญ: โค้ดชุดเดียวต้องรันได้ทุก environment แค่เปลี่ยนค่า env ไม่ต้องแก้โค้ด:

javascript
// ✅ อ่านจาก environment
const PORT = Number(process.env.PORT) || 3000;
const DATABASE_URL = process.env.DATABASE_URL;

2. .env file + validate ตอน startup

ตอน dev จะพิมพ์ env ยาว ๆ หน้า command ทุกครั้งก็ลำบาก — เก็บไว้ในไฟล์ .env แทน:

bash
# .env — ไฟล์นี้ใส่ .gitignore เสมอ! (มีความลับ)
PORT=8080
DATABASE_URL=postgres://localhost:5432/myapp
JWT_SECRET=dev-secret-change-in-prod
NODE_ENV=development

โหลดเข้า process.env — Node มีตัวช่วย built-in แล้ว ไม่ต้องลง dotenv (แนะนำเป็นค่ามาตรฐานปี 2026):

bash
node --env-file=.env app.js                 # โหลด .env เข้า process.env ก่อนรัน
node --env-file-if-exists=.env app.js       # (Node 22+) ถ้าไฟล์ไม่มี → ไม่ error

💡 ประวัติเวอร์ชัน: --env-file มาใน Node v20.6 (experimental) → stable ใน v20.12 · --env-file-if-exists มาใน Node 22 (เหมาะกับ production ที่อาจไม่มี .env)

ข้อจำกัด: --env-file ไม่ทำ variable expansion (${OTHER_VAR}) และไม่ support comment ที่อยู่หลังค่า (# ต้องอยู่ต้นบรรทัดอย่างเดียว) — ถ้าต้องการ feature เหล่านี้ ค่อยใช้ dotenv

ถ้ายังต้อง support Node เวอร์ชันเก่า หรือใช้ feature ที่ built-in ไม่มี (เช่น dotenv-expand) ใช้ package dotenv:

javascript
import "dotenv/config";   // โหลด .env อัตโนมัติ — วางบรรทัดบนสุดสุด
console.log(process.env.PORT);

⭐ Validate config ตอน startup — "fail fast"

process.env.X เป็น string หรือ undefined เสมอ (บทที่ 2) — ถ้าลืมตั้ง env สำคัญ แล้วรู้ตัวตอน request ที่ 1000 เข้ามากลางดึก = หายนะ ตรวจ config ให้ครบตั้งแต่ตอน boot แล้ว crash ทันทีถ้าขาด (fail fast ดีกว่า fail later):

javascript
// config.mjs — รวม config + validate ที่เดียว
function required(name) {
  const value = process.env[name];
  if (!value) {
    // ขาด config สำคัญ → ตายทันทีตอน startup พร้อมบอกว่าขาดอะไร
    console.error(`❌ ต้องตั้ง environment variable: ${name}`);
    process.exit(1);
  }
  return value;
}

export const config = {
  port: Number(process.env.PORT) || 3000,            // มี default ได้
  databaseUrl: required("DATABASE_URL"),             // บังคับ
  jwtSecret: required("JWT_SECRET"),                 // บังคับ
  isProd: process.env.NODE_ENV === "production",
};

💡 โปรเจกต์จริงนิยม validate ด้วย Zod — schema (โครงสร้าง/รูปแบบข้อมูลที่กำหนดไว้ล่วงหน้า) validation library ที่ให้ประกาศ schema แล้วใช้ตรวจ runtime + ได้ type อัตโนมัติ (ถ้าใช้ TypeScript) ติดตั้งด้วย npm i zod แล้วใช้แบบนี้:

javascript
import { z } from "zod";
const env = z.object({
  PORT: z.coerce.number().default(3000),       // coerce = บังคับแปลงชนิดข้อมูลให้ตรงที่กำหนด เช่น string → number
  DATABASE_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "production", "test"]),
}).parse(process.env);    // ผิด → throw พร้อมบอกว่า field ไหนพัง

ได้ทั้ง validate + type ที่ถูกต้องในตัวเดียว — รายละเอียดเต็มที่ TypeScript บทที่ 11 (ในภาค TS ของหนังสือ)

.env.example — บอกทีมว่าต้องตั้งอะไรบ้าง

.env จริงอยู่ใน .gitignore (มีความลับ) — แต่ commit ไฟล์ .env.example (ไม่มีค่าจริง) ขึ้น git เพื่อบอกเพื่อนร่วมทีมว่าต้องตั้ง env อะไรบ้าง:

bash
# .env.example — commit ขึ้น git ได้ (ไม่มีความลับจริง)
PORT=3000
DATABASE_URL=
JWT_SECRET=
NODE_ENV=development

⚠️ Pitfall ที่ทำคนเดือดร้อนบ่อย: เผลอ commit .env ที่มีรหัสจริงขึ้น git สาธารณะ → ถือว่ารหัสรั่วแล้วแม้จะลบทีหลัง (git เก็บประวัติ) ต้องเปลี่ยนรหัสใหม่ทันที · ใส่ .env ใน .gitignore ตั้งแต่บรรทัดแรกของโปรเจกต์


3. สร้าง CLI tool

หลาย tool ที่คุณใช้ (eslint, vite) คือโปรแกรม Node ที่รับ argument จาก command line

flag/option คือค่าที่ใส่ต่อท้ายคำสั่งแบบ --ชื่อ (เช่น --verbose, --name สมชาย) เพื่อบอกโปรแกรมว่าต้องทำอะไรพิเศษ — Node เก็บ argument ดิบ ๆ ไว้ใน process.argv (array ของ string, ดูรายละเอียดใน บทที่ 2 ถ้ายังไม่เคยอ่าน) แต่พอมี flag เยอะ ๆ การ parse process.argv เองจะยุ่ง Node มี util.parseArgs built-in ช่วย:

javascript
// greet.mjs — รัน: node greet.mjs --name สมชาย --times 3 --shout
import { parseArgs } from "node:util";

const { values } = parseArgs({
  options: {
    name:  { type: "string",  short: "n" },           // --name หรือ -n
    times: { type: "string",  short: "t", default: "1" },   // ⚠️ parseArgs ไม่มี type "number" — รับเป็น string แล้วแปลงเอง
    shout: { type: "boolean", short: "s", default: false },  // flag ไม่มีค่า
  },
});

const times = Number(values.times);   // แปลงเอง — เช็ก Number.isFinite ถ้าผู้ใช้พิมพ์มั่ว ๆ
let msg = `สวัสดี ${values.name ?? "ไม่ทราบชื่อ"}`;
if (values.shout) msg = msg.toUpperCase() + "!!!";

for (let i = 0; i < times; i++) console.log(msg);
bash
$ node greet.mjs --name สมชาย --times 2 --shout
สวัสดี สมชาย!!!
สวัสดี สมชาย!!!

💡 ถ้า CLI ซับซ้อน (มี subcommand เช่น git commit, help อัตโนมัติ, validation) ใช้ library เช่น commander หรือ yargs — แต่งานง่าย ๆ parseArgs built-in พอแล้ว ไม่ต้องลง dependency

ทำให้รันเป็นคำสั่งได้ (shebang + bin)

shebang (อ่าน "ชีแบง") = บรรทัด #! บรรทัดแรกของไฟล์ ที่บอก OS ว่าให้ใช้โปรแกรมไหนรันไฟล์นี้:

javascript
#!/usr/bin/env node
// บรรทัดบนสุด (shebang) บอก OS ว่า "รันไฟล์นี้ด้วย node"
console.log("ฉันเป็น CLI tool!");

⚠️ Windows caveat: shebang ทำงานเฉพาะ Unix (macOS/Linux/WSL) — บน Windows ต้องเรียก node cli.mjs ตรง ๆ · ถ้า publish เป็น npm package แล้วลง global, npm จะสร้าง .cmd wrapper ให้ทำงานบน Windows ได้

ทีนี้ถ้าอยากให้เรียกไฟล์นี้เป็น "คำสั่ง" ได้เลย (พิมพ์ mytool แทน node cli.mjs) ต้องลงทะเบียนผ่าน bin ใน package.jsonbin คือ field ที่ผูกชื่อคำสั่งเข้ากับไฟล์ตัวนี้:

jsonc
"bin": { "mytool": "./cli.mjs" }

💡 npm link = ติดตั้ง package ที่อยู่ใน folder ปัจจุบันเข้าเครื่องแบบ global (โดยลิงก์ไฟล์ ไม่ copy) — ใช้ทดสอบ CLI ของตัวเองก่อน publish · ยกเลิกด้วย npm unlink -g <ชื่อ>


4. Graceful shutdown — ปิดอย่างมีมารยาท

เมื่อ deploy เวอร์ชันใหม่ หรือ scale ลด, ระบบจะสั่งปิด process เก่า ถ้าปิดดื้อ ๆ — request ที่กำลังประมวลผลอยู่จะถูกตัดกลางคัน (user เห็น error, transaction ค้างครึ่งทาง)

graceful shutdown = "หยุดรับงานใหม่ → ทำงานที่ค้างให้จบ → ปิด resource → ค่อยตาย (terminate/exit process)"

Signal คืออะไร

ลองนึกภาพเหมือนคนเคาะประตูบอก "จะปิดร้านแล้วนะ" — ร้านไม่ปิดทันที แต่รอให้ลูกค้าที่กำลังจ่ายเงินอยู่จ่ายให้เสร็จก่อน นั่นคือหลักการของ signal: ระบบปฏิบัติการ "เคาะประตู" บอกโปรแกรมให้ปิดตัว ผ่านการส่ง signal ชนิดต่าง ๆ — Node ดักฟัง signal ได้ผ่าน process.on (บทที่ 2)

มี signal หลักที่ต้องรู้จัก 3 ตัว:

SIGTERM — "ขอให้ปิดด้วยนะ" เป็นมิตร ดักได้ (โปรแกรมมีโอกาสทำความสะอาดก่อนปิด) Docker (เครื่องมือ package + รันแอปใน container) และ Kubernetes/k8s (ระบบ orchestrate container ระดับ cluster) ส่ง signal นี้ตอน stop/redeploy — รายละเอียดสองตัวนี้จะลงลึกในบทที่ 11

SIGINT — เกิดตอนกด Ctrl+C ใน terminal ดักได้เช่นกัน

SIGUSR2 — signal "user-defined" (ผู้ใช้กำหนดเอง) — nodemon ใช้ส่งตัวนี้ตอน restart ระหว่าง dev ถ้าเขียน graceful shutdown ก็ควรดักด้วย ไม่งั้นทุกครั้งที่ nodemon reload connection จะถูกตัดดื้อ ๆ

ส่วน SIGKILL ดักไม่ได้ ตายทันที — k8s ส่งตัวนี้หลังหมด terminationGracePeriodSeconds ถ้าเรายังไม่ยอม exit เอง

javascript
import express from "express";
const app = express();

let isShuttingDown = false;
// readiness probe: ระหว่างปิด ตอบ 503 ให้ load balancer หยุดส่ง traffic ใหม่ก่อน
app.get("/health/ready", (req, res) =>
  res.status(isShuttingDown ? 503 : 200).send(isShuttingDown ? "shutting down" : "ok")
);
app.get("/", (req, res) => res.send("ok"));

const server = app.listen(3000, () => console.log("รันแล้ว"));

// AbortController = object ที่ส่ง "สัญญาณยกเลิก" ให้ async operation ที่รองรับมันได้
// เรียก .abort() ครั้งเดียว → ทุกงานที่ผูก .signal ไว้จะถูกยกเลิกพร้อมกัน
// ใช้ยกเลิกงาน background (DB queries ค้าง, fetch ค้าง ฯลฯ) ตอน shutdown
const shutdownAbort = new AbortController();

// ฟังก์ชันปิดอย่างนุ่มนวล
async function shutdown(signal) {
  if (isShuttingDown) return;     // กันโดน signal ซ้ำ (Ctrl+C สองครั้ง)
  isShuttingDown = true;
  console.log(`\nได้รับ ${signal} — กำลังปิดอย่างนุ่มนวล...`);

  // 1. flip readiness probe ปิดก่อน → รอให้ load balancer drain
  //    (k8s pattern: readiness=false → รอ ~5s → ค่อย close server)
  //    ถ้า run ใน k8s ใส่ sleep ตรงนี้ตาม terminationGracePeriodSeconds
  await new Promise((r) => setTimeout(r, 1_000));

  // 2. หยุดรับ connection ใหม่ (request ที่ค้างอยู่ยังทำต่อจนจบ)
  //    ⭐ สำคัญ: process.exit() ต้องรอ callback ของ server.close() ก่อนเสมอ
  //    เพราะ server.close() ปิดแบบ async — ถ้า exit ก่อน callback ทำงาน
  //    request ที่ยังค้างอยู่จะถูกตัดกลางคัน ขัดกับเป้าหมายของ graceful shutdown เอง
  server.close(async (err) => {
    if (err) console.error("server.close error:", err);
    console.log("ปิดรับ request ใหม่แล้ว");

    try {
      // 3. ยกเลิกงาน background ที่ใช้ AbortSignal
      shutdownAbort.abort();
      // 4. ปิด resource อื่น: DB pool, message queue, ฯลฯ
      // await db.end();
      console.log("ปิด resource เรียบร้อย — บ๊ายบาย");
      process.exit(0);   // จบด้วย code 0 = สำเร็จ — เรียกหลัง server.close() เสร็จจริงเท่านั้น
    } catch (err) {
      console.error("error ตอนปิด resource:", err);
      process.exit(1);
    }
  });

  // ⭐ ปิด keep-alive sockets — server.close() จะ "รอ" connection พวกนี้ตลอดกาล
  //    เพราะ HTTP keep-alive เปิดค้างไว้ใช้ซ้ำ ไม่มีใครปิดให้ (อธิบายเพิ่มด้านล่าง หัวข้อ "ทำไมต้อง closeIdleConnections()")
  //    ⚠️ ต้องใช้ Node 18.2+ ไม่งั้น method นี้ไม่มี — throw TypeError "is not a function"
  server.closeIdleConnections();     // ปิด socket ที่ idle ทันที (ไม่มี request ค้าง)

  // ให้ socket ที่กำลังส่ง response อยู่ทำงานต่อสักพัก แล้วค่อยบังคับปิด
  setTimeout(() => {
    server.closeAllConnections();    // ปิด socket ที่เหลือทุกตัว (รวมที่ยัง active) — ต้องใช้ Node 18.2+ เช่นกัน
  }, 5_000).unref();
}

// 6. ตาข่ายกันค้าง: ถ้าปิดไม่เสร็จใน 10 วิ บังคับตาย (อย่าค้างตลอดกาล)
function armForceExit() {
  setTimeout(() => {
    console.error("ปิดไม่เสร็จใน 10 วิ — บังคับปิด");
    process.exit(1);
  }, 10_000).unref();   // unref() = ไม่ให้ timer นี้กัน process ไม่ให้จบ
}

// ดัก signal
process.on("SIGTERM", () => { armForceExit(); shutdown("SIGTERM"); });   // จาก docker/k8s
process.on("SIGINT",  () => { armForceExit(); shutdown("SIGINT");  });   // จาก Ctrl+C
process.on("SIGUSR2", () => { armForceExit(); shutdown("SIGUSR2"); });   // จาก nodemon restart

ลองเอง: รัน server แล้วกด Ctrl+C — จะเห็นมันปิดเป็นขั้นตอน ไม่ตายทันที

ทำไมต้อง closeIdleConnections() + closeAllConnections()

(keep-alive = socket ที่เปิดค้างไว้ใช้ซ้ำ — ตามที่กล่าวถึงไปแล้วด้านบน)

HTTP/1.1 ใช้ keep-alive เป็น default — client ส่ง request เสร็จแล้ว socket ยัง "เปิดค้าง" ไว้เพื่อใช้ซ้ำ · server.close() รอจนทุก connection ปิดเอง แต่ keep-alive socket ไม่มีใครปิดให้ → server "ค้าง" ตลอดกาล จนโดน force exit ที่ 10 วิ (deploy ช้า, log สับสน)

Node 18.2+ เพิ่ม 2 method นี้แก้ปัญหา:

  • server.closeIdleConnections() — ปิดเฉพาะ socket ที่ idle (ไม่มี request ค้าง) ใช้คู่กับ server.close() ตอนเริ่ม shutdown
  • server.closeAllConnections() — ปิด socket ทุกตัว รวมที่ยังส่ง response อยู่ ใช้ตอนหมดเวลา grace period

AbortController สำหรับ background work

ถ้าแอปมีงาน async ที่รันใน background (เช่น polling, batch job, fetch ภายนอก) ให้ pass AbortSignal เข้าไป — ตอน shutdown ก็ abort() ทีเดียว ยกเลิกได้หมด:

javascript
// ในที่ที่ทำงาน background
const res = await fetch(url, { signal: shutdownAbort.signal });   // จะ throw AbortError ตอน shutdown
await someLongTask({ signal: shutdownAbort.signal });

⚠️ Pitfall:

  • ลืม server.close() แล้ว process.exit() เลย → request ที่กำลังตอบครึ่งทางถูกตัด user เจอ connection reset
  • ลืม closeIdleConnections()/closeAllConnections() → keep-alive socket ค้าง deploy ช้าทุกครั้ง
  • ลืม timeout กันค้าง → connection ดื้อ (เช่น websocket) ทำให้ process ค้างไม่ยอมตาย

💡 หมายเหตุล่วงหน้า: NestJS มี "lifecycle hooks" (onModuleDestroy, enableShutdownHooks()) ที่ wrap pattern นี้ให้เป็นระบบ — ข้างใต้ก็คือ SIGTERM + ปิด resource แบบนี้แหละ ถ้ายังไม่ถึงภาค Nest ข้ามได้ จะมีบทเฉพาะที่ NestJS บทที่ 8


🛠️ Checkpoint 7 — ลงมือก่อนไปบทถัดไป

  1. สร้าง .env (PORT, DATABASE_URL) + .env.example + เพิ่ม .env ใน .gitignore แล้วรันด้วย node --env-file=.env app.js ให้อ่านค่าได้
  2. เขียน config.mjs ที่ validate ว่ามี DATABASE_URL ไหม — ถ้าไม่มี process.exit(1) พร้อมข้อความ ลองรันทั้งแบบมีและไม่มี
  3. ทำ CLI ด้วย parseArgs รับ --file <path> แล้วอ่านไฟล์นั้นพิมพ์จำนวนบรรทัด (รวมกับ readline บทที่ 4)
  4. เพิ่ม graceful shutdown ให้ Express server — กด Ctrl+C แล้วสังเกต log ว่าปิดเป็นขั้นตอน
  5. ทดสอบ timeout กันค้าง: ใส่ route ที่หน่วงเวลา 30 วิ (setTimeout) เปิด request ค้างไว้ แล้วกด Ctrl+C — ดูว่า server รอ request จบ หรือบังคับปิดที่ 10 วิ
  6. (ถ้ามี Zod) เปลี่ยน config.mjs ให้ validate ด้วย Zod schema แทนการเช็กมือ

เฉลยข้อ 2: ดูโครง required() ในข้อ 2 ของบท — เรียก required("DATABASE_URL") ตอน import config


สรุปบทที่ 7

  • Config ผ่าน environment (12-factor) — อย่าฮาร์ดโค้ดรหัส/URL ลงโค้ด · โค้ดชุดเดียวรันได้ทุก env
  • .env เก็บ config ตอน dev → โหลดด้วย node --env-file=.env (built-in) หรือ dotenv · .env ต้องอยู่ใน .gitignore · commit .env.example แทน
  • Validate config ตอน startup (fail fast) — ขาด env สำคัญให้ crash ทันที ไม่ใช่ค้นพบตอน request เข้า · Zod ช่วยได้
  • CLI tool ใช้ util.parseArgs (built-in) สำหรับ flag/option · ซับซ้อนค่อยใช้ commander/yargs
  • Graceful shutdown: ดัก SIGTERM/SIGINT/SIGUSR2 → flip readiness=false → server.close() + closeIdleConnections() / closeAllConnections() (จัดการ keep-alive) → AbortController.abort() ยกเลิกงาน background → ปิด DB/resource → exit · มี timeout กันค้าง เสมอ

บทต่อไป: เขียนเทสต์node:test built-in, Vitest, mock, และทดสอบ HTTP API ด้วย supertest

→ บทที่ 8: Testing