Skip to content

13.3 Process manager & startup

บทนี้อธิบายว่าแต่ละ service เริ่มต้นอย่างไร ใช้ process manager ใด และทำไมค่าต่าง ๆ จึงตั้งอย่างที่เป็น

ภาพรวม startup

ServiceProcess managerStart commandEntry pointฟังก์ชันเริ่มต้น
vtrc-apiDocker restart: alwaysnpm start = node -r esm .api/index.jsapp.listen(APP_PORT)เปิด port, เชื่อมต่อ db, db2, redis, start cron
cu-central-apiDocker restart: alwaysnpm start = node -r esm .main-api/index.jsimport './src'เช่นเดียวกัน
centralize-apiDocker (PM2 ไม่ได้ใช้)yarn start:prod = node dist/src/mainsrc/main.tsapp.listen(port)bootstrap NestJS
vtrc-webnginx (foreground)nginx defaultไม่มี — serve staticnginx โหลด default.conf
vtrc-rc-backofficenginx (foreground)nginx defaultไม่มี — serve staticnginx โหลด default.conf

vtrc-api — เริ่มต้นผ่าน node -r esm

vtrc-api/api/package.json scripts:

json
{
  "start": "node -r esm .",
  "run": "nodemon -r esm"
}

หมายความว่า:

  • node -r esm — Node เริ่มต้นด้วยการ preload package esm ซึ่งเป็น shim ที่ทำให้ Node 12 เข้าใจ import/export syntax ของ ES Module (รายละเอียดใน Volume 3)
  • . — Node จะมองหา index.js ใน directory ปัจจุบัน (จาก package.json:main) นั่นคือ api/index.js
  • ใน production ใช้ Docker restart: always แทน PM2 — ถ้า Node crash, Docker daemon จะ restart container ให้
  • npm run สำหรับพัฒนา — ใช้ nodemon รีสตาร์ทอัตโนมัติเมื่อไฟล์เปลี่ยน

ลำดับการเริ่มต้นของ vtrc-api (vtrc-api/api/src/index.js:1-121):

1. import express, routes, typeDefs, resolvers, ApolloServer, config, ...
2. สร้าง Express app + ติดตั้ง middleware (cors, bodyParser, graphqlUploadExpress)
3. สร้าง ApolloServer (typeDefs + resolvers + AuthDirective)
4. applyMiddleware ของ Apollo ลงบน Express
5. import './crons' → start scheduler (บทที่ 11)
6. app.listen(APP_PORT) → เริ่มฟัง port
7. redisConnection() → เชื่อมต่อ Redis
8. db.authenticate() → เชื่อมต่อ MariaDB schema 'vtrc'
9. syncModelToTable() → sync schema อัตโนมัติ (รายละเอียด Volume 8)
10. db2.authenticate() → เชื่อมต่อ MariaDB schema 'vtrc-centralize'
11. createDatabases(db2) → สร้าง database ถ้ายังไม่มี

จุดสำคัญ:

  • ไม่มี readiness checkapp.listen ทำงานทันที แม้ว่า db จะยังเชื่อมต่อไม่ได้ ทำให้ nginx อาจส่ง traffic เข้ามาตอนที่ service ยังไม่พร้อม
  • sync schema ทุกครั้งที่ startsyncModelToTable() รันทุกครั้ง ถ้าโมเดลมีการแก้ไข schema ใน code จะถูกนำไปใช้กับ database ทันทีโดยไม่มี migration review (รายละเอียด Volume 8 บทที่ 4)

cu-central-api — PM2 ecosystem ที่ไม่ได้ใช้

cu-central-api/ecosystem.config.js:

javascript
module.exports = {
  apps: [{
    name: 'cu-central-api',
    script: './index.js',
    node_args: '-r esm .',
    log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
    error_file: './logs/carpark-api-error.log',
    out_file: './logs/carpark-api.log',
    autorestart: true,
    watch: false,
    env: {
      NODE_ENV: 'development'
    },
    env_production: {
      APP_PORT: '8000',
      NODE_ENV: 'production'
    }
  }]
}

ไฟล์นี้กำหนดให้ PM2 เป็น process manager — แต่ Dockerfile ของ cu-central-api ไม่ได้ใช้ PM2:

dockerfile
CMD ["npm", "start"]

ความขัดแย้งนี้บ่งชี้ว่า PM2 เคยใช้ในอดีตก่อนที่จะย้ายมารันบน Docker และ ecosystem.config.js ถูกทิ้งไว้โดยไม่ได้ใช้

จุดสังเกตเพิ่มเติม:

  • ชื่อ log file คือ carpark-api.log — บ่งชี้ว่า codebase นี้คัดลอกมาจากโครงการอื่น (carpark) และไม่ได้ปรับชื่อ
  • env.NODE_ENV: 'development' เป็น default — ถ้ารันผ่าน PM2 โดยไม่ระบุ --env production จะได้สภาพแวดล้อม development ทั้ง ๆ ที่อยู่ใน production server

centralize-api — bootstrap ของ NestJS

centralize-api/src/main.ts:12-44:

typescript
async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    logger: new I3GatewayLogger('System'),
  });

  const port = config.get('port') || 3000;
  app.setGlobalPrefix('api');
  app.use(bodyParser.json({ limit: '50mb' }));
  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalGuards(new JwtAuthGuard(reflector));
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
  app.enableCors({ origin: '*', allowedHeaders: '*', methods: 'GET,PUT,POST,DELETE,PATCH,OPTIONS' });

  SwaggerModule.setup(`docs`, app, document);
  await app.listen(port, '0.0.0.0', () => {
    logger.log(`Listening at http://localhost:${port}/docs`);
  });
}
bootstrap();

จุดสังเกต:

  • I3GatewayLogger — centralize-api เป็น service เดียวที่ใช้ logger ที่กำหนดเอง (libs/log/logs.middleware.ts) แทน console.log
  • origin: '*' — CORS เปิดกว้างทุก origin (main.ts:31-35) เป็นปัญหาด้านความปลอดภัย (รายละเอียด Volume 9 บทที่ 1)
  • start:prodpackage.json:start:prod = node dist/src/main — รันจาก compiled output ใน dist/
  • port: 3000 เริ่มต้น — แต่อ่านจาก ConfigService ได้ถ้ามี env var
  • listen ที่ 0.0.0.0 — รับจากทุก interface เหมาะสำหรับ container

vtrc-web & vtrc-rc-backoffice — static file hosting

ทั้งคู่ไม่มี Node process รันใน production — Docker build stage ที่ 2 ใช้ nginx serve static file จาก build/

  • vtrc-webvtrc-web/Dockerfile:11-19 ใช้ nginx 1.24.0 + nginx-config/default.conf
  • vtrc-rc-backofficevtrc-rc-backoffice/Dockerfile:13-17 ใช้ nginx 1.24.0 + nginx/default.conf

nginx รันใน foreground ตาม default ของ image — ถ้า nginx process ตาย Docker daemon จะ restart container ผ่าน restart: always ใน docker-compose

APP_NO — ตัวระบุ replica

ใน docker-compose ของ vtrc-api และ cu-central-api มีการส่ง environment variable APP_NO ที่แตกต่างกันในแต่ละ replica:

ServiceAPP_NO ที่ใช้
vtrc-api1, 2, 3, 5 (ข้าม 4)
cu-central-api1, 2, 3

APP_NO ถูกใช้เป็นตัวระบุ replica และเป็น gate ของ cron บางตัวเท่านั้น:

  • vtrc-api (api/src/crons.js): if (+APP_NO === 1) ครอบคลุมเฉพาะ updateSuccessFundChangeRateByJobupdateDelegatesExpire และ statLog ลงทะเบียนนอกเงื่อนไขนี้ จึงรันบนทุก replica
  • cu-central-api (main-api/src/cron.js): job ทั้งหมดอยู่ใน if (+APP_NO === 1)

จุดสังเกต:

  • APP_NO=4 ถูกข้าม ใน vtrc-api — บ่งชี้ว่ามี replica ที่ถูกปิดไป (อาจด้วยเหตุผลด้านทรัพยากร) แต่ไม่มีเอกสารอธิบาย
  • อย่าสรุปว่า APP_NO=1 ตายแล้ว cron ทั้งระบบหยุด — ที่ vtrc มีแค่ fund rate job ที่เป็น SPOF ของ APP_NO=1; delegate expire / statLog ยังรันบน replica อื่น (แต่เสี่ยงรันซ้ำ)
  • APP_NO gate ไม่ใช่ distributed lock ที่แท้จริง — ถ้า replica 1 ตาย updateSuccessFundChangeRateByJob (และ cron ของ cu-central) จะไม่รันจนกว่า replica 1 จะกลับมา online

สิ่งที่ขาด — readiness/liveness probe

ไม่มี service ใดรายงานสถานะ readiness หรือ liveness ผ่าน HTTP endpoint ทำให้:

  • Docker compose ไม่สามารถรู้ได้ว่า Node process พร้อมรับ request หรือไม่ (ยกเว้น port เปิดหรือไม่)
  • nginx upstream ส่ง traffic ไป replica ที่ยัง boot อยู่ ทำให้ผู้ใช้ได้รับ error 502 หรือ connection refused
  • การ deploy ใหม่ต้องรอด้วยตนเองก่อนตัด traffic เข้า

ข้อแนะนำเพื่อปรับปรุง

  1. เพิ่ม endpoint /health ในทุก backend service — ส่งคืน 200 เมื่อ db, redis เชื่อมต่อได้
  2. ใช้ readiness gate ก่อนฟัง port — อย่าเรียก app.listen จนกว่าการเชื่อมต่อ db จะสำเร็จ
  3. ลบ ecosystem.config.js ของ cu-central-api — หรือปรับให้ใช้จริง ถ้าทีมตัดสินใจกลับไปใช้ PM2
  4. เปลี่ยน APP_NO gate เป็น distributed lock — ใช้ Redis SET NX สำหรับ job ที่ต้องรันครั้งเดียวต่อ cluster และเพิ่ม idempotency ให้ job ที่รันทุก replica อยู่แล้ว