Part of 10306493 การศึกษาหัวข้อสนใจด้านเทคโนโลยีสารสนเทศ (Selected Topic in Information Technology)

2026-09-23 · updated 2026-10-05 · 19 min read

Lab: รัน Full Stack ในเครื่องด้วย Docker Compose พร้อม Hot Reload (React + Vite + Nginx + json-server)

Lab: รัน Full Stack ในเครื่องด้วย Docker Compose พร้อม Hot Reload (React + Vite + Nginx + json-server)

แอปจริงแทบไม่เคยมีแค่ container เดียว หน้าเว็บ React ต้องคุยกับ API และในระบบจริงก็มักมี Nginx คั่นอยู่ตรงกลาง บทความนี้พาเขียน Docker Compose ที่รันทั้งสามส่วนด้วยคำสั่งเดียว แล้วเพิ่มไฟล์ override ให้แก้โค้ดแล้วเห็นผลในเบราว์เซอร์ทันที (Hot Reload) โดยไม่ต้อง build image ใหม่

ทุกไฟล์ในบทความนี้ รันทดสอบจริงแล้ว ผลลัพธ์ในกรอบ output และตัวเลขเวลาที่อ้างถึงเป็นค่าที่วัดได้จากเครื่องทดสอบ ไม่ได้ประมาณเอา

สิ่งที่คุณจะได้เรียนรู้

  1. เขียน docker-compose.yml ที่รัน frontend (Nginx) คู่กับ JSON API และให้ service เรียกกันด้วยชื่อ
  2. เขียน Dockerfile แบบ multi-stage ไฟล์เดียวที่สร้างได้ทั้ง image สำหรับ dev และ prod
  3. ตั้งค่า Nginx ให้เสิร์ฟ SPA และส่งต่อ /api ไปยัง API (reverse proxy)
  4. ใช้ docker-compose.override.yml เปลี่ยนสแต็กเดิมเป็นโหมดพัฒนาที่มี HMR โดยไม่ต้องแก้ไฟล์หลัก
  5. เข้าใจ กฎการรวมไฟล์ ของ Compose (แทนที่ / ต่อท้าย / !override)
  6. ตรวจสอบและแก้ปัญหาสแต็กด้วย Docker Desktop และคำสั่ง docker compose
📌เหมาะกับใคร และทดสอบกับอะไร

ผู้ที่เขียน React มาบ้างและเคยใช้ docker build / docker run มาก่อน (นักศึกษารายวิชา 10306493 สัปดาห์ที่ 7) ใช้เวลาประมาณ 2 ชั่วโมง

ทดสอบเมื่อ 22 กันยายน 2569 บน Windows 11 ด้วย Docker Engine 29.8.0, Docker Compose v5.5.1, Node.js 22.19 (บนเครื่อง), image node:24-alpine และ nginx:1.31-alpine, Vite 8.3.0, React 19.3, Redux Toolkit 2.12.0, react-redux 9.3.0 และ json-server 1.0.0-beta.15

💡บทความนี้ต่างจากสไลด์ 5 จุด (ตั้งใจ และทดสอบแล้ว)

ไฟล์ในสไลด์ใช้อธิบายแนวคิดได้ดี แต่เมื่อรันตามทุกตัวอักษรบน Docker Compose v5.5 จะติดปัญหา บทความนี้จึงแก้ไว้ดังนี้

  1. Dockerfile แบบ multi-stage แทน image: node:20-alpine ใน override: เมื่อรันตามสไลด์ service web จบการทำงานทันทีด้วย Exited (127) และ log แสดง sh: vite: not found เพราะ volume /app/node_modules ว่างและไม่มีใครสั่ง npm install บทความนี้ให้ image ของ dev ติดตั้ง dependency ไว้ตั้งแต่ตอน build แล้วเลือกด้วย target
  2. Node 24 แทน Node 20: Node 20 หมดระยะสนับสนุน (End-of-Life) ไปแล้วเมื่อ 30 เมษายน 2026 ตามตาราง release ของ Node.js ปัจจุบัน Node 24 เป็นรุ่น LTS
  3. ตั้ง proxy ใน vite.config.js: ในโหมด dev ไม่มี Nginx คอยส่งต่อ /api ถ้าไม่ตั้ง proxy แอปจะดึงข้อมูลสินค้าไม่ได้
  4. เปิด polling ให้ Vite: บน Windows ถ้าไม่เปิด polling แล้วแก้ไฟล์ Vite จะไม่รู้ตัวเลย (ทดสอบรอ 20 วินาทีแล้วหน้าเว็บก็ไม่เปลี่ยน) ในสไลด์ข้อนี้อยู่แค่ในหน้า Troubleshooting บทความนี้จึงให้เป็นขั้นตอนหลัก
  5. pin เวอร์ชัน json-server: ในสไลด์ npx json-server บน Node 20 ได้รุ่น 0.17.4 เพราะรุ่นล่าสุด 1.0.0-beta.15 ต้องใช้ Node ≥ 22.12 ถ้าไม่ pin ไว้ เวอร์ชันที่ได้จะขึ้นกับ Node ที่ใช้อยู่ บทความนี้จึงติดตั้ง json-server เป็น dependency ที่ระบุเวอร์ชันชัดเจน

ภาพรวมระบบที่จะสร้าง

แผนผังแสดงเบราว์เซอร์เชื่อมต่อพอร์ต 8080 ไปยัง container web ที่รัน Nginx ซึ่งเสิร์ฟไฟล์ static และส่งต่อ /api ไปยัง container api ที่รัน json-server พอร์ต 3001 ทั้งหมดอยู่ใน Docker network เดียวกัน และ api ต่อ bind mount ไปยังไฟล์ db.json บนเครื่อง
รูปที่ 0.1 สถาปัตยกรรมของแลบในโหมด prod-like
ServiceImageหน้าที่พอร์ต
webbuild จาก frontend/Dockerfile (stage prod = Nginx)เสิร์ฟไฟล์ React ที่ build แล้ว และส่งต่อ /api/ ไปยัง apiเปิดให้เครื่องเราที่ 8080
apibuild จาก api/Dockerfile (Node 24 + json-server)REST API จำลองจากไฟล์ db.json3001 ใช้กันภายใน network เท่านั้น

ประเด็นสำคัญของภาพนี้คือ เบราว์เซอร์คุยกับ localhost:8080 ที่เดียว ทั้งหน้าเว็บและ /api มาจาก origin เดียวกัน จึงไม่ต้องตั้งค่า CORS ส่วน api ไม่ได้ publish พอร์ตออกมานอก network เพราะมีแค่ web ที่ต้องเรียกใช้


สิ่งที่ต้องเตรียมก่อนเริ่ม

  1. Docker Desktop ที่รันอยู่ ตรวจด้วยคำสั่งด้านล่าง (ต้องได้เวอร์ชันของ Server และ Compose ไม่ใช่ error)
POWERSHELL
docker version --format "{{.Server.Version}}"
docker compose version
  1. Node.js 22 ขึ้นไปบนเครื่อง ใช้สร้างโปรเจกต์และสร้างไฟล์ package-lock.json ส่วนตอนรันจริงใช้ Node ใน container
  2. พอร์ต 8080 และ 5173 ต้องว่าง (ถ้าเปิด npm run dev ค้างไว้จากแลบก่อน ให้ปิดก่อน)
  3. โปรแกรมแก้โค้ด เช่น VS Code และเทอร์มินัล (PowerShell, Command Prompt หรือ Git Bash ก็ได้)

ขั้นที่ 0: เตรียมแอป React-Redux (15 นาที, ข้ามได้)

ℹ️มีแอปจากสัปดาห์ก่อนแล้ว?

ข้ามขั้นนี้ได้ แต่ต้องตรวจสองข้อ (1) แอปสร้างด้วย Vite และ (2) ทุกจุดที่เรียก API ต้องใช้ path แบบ relative เช่น fetch('/api/products') ห้ามเขียน http://localhost:3001/... ตรง ๆ เพราะเมื่ออยู่ใน container แล้ว localhost จะหมายถึงตัว container เอง ไม่ใช่เครื่องของเรา

สร้างโฟลเดอร์ myapp แล้วสร้างโปรเจกต์ Vite (template React) ไว้ในโฟลเดอร์ย่อย frontend

POWERSHELL
mkdir myapp
cd myapp
npm create vite@latest frontend -- --template react --no-interactive
cd frontend
npm install
npm install @reduxjs/toolkit react-redux

ต่อไปสร้าง slice สำหรับดึงรายการสินค้า โดยใช้ createAsyncThunk ของ Redux Toolkit

frontend/src/features/products/productsSlice.js

JS
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
 
// เรียก API ด้วย path แบบ relative เสมอ ("/api/...")
// โหมด prod: Nginx ส่งต่อให้ api / โหมด dev: Vite proxy ส่งต่อให้ api
export const fetchProducts = createAsyncThunk('products/fetchAll', async () => {
  const res = await fetch('/api/products')
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
})
 
const productsSlice = createSlice({
  name: 'products',
  initialState: { items: [], status: 'idle', error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchProducts.pending, (state) => {
        state.status = 'loading'
      })
      .addCase(fetchProducts.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.items = action.payload
      })
      .addCase(fetchProducts.rejected, (state, action) => {
        state.status = 'failed'
        state.error = action.error.message
      })
  },
})
 
export default productsSlice.reducer

frontend/src/store.js

JS
import { configureStore } from '@reduxjs/toolkit'
import productsReducer from './features/products/productsSlice'
 
export const store = configureStore({
  reducer: { products: productsReducer },
})

แก้ main.jsx ให้ครอบแอปด้วย Provider

frontend/src/main.jsx

JSX
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import './index.css'
import App from './App.jsx'
import { store } from './store'
 
createRoot(document.getElementById('root')).render(
  <StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </StrictMode>,
)

แทนที่ App.jsx และ App.css เดิมทั้งไฟล์ แล้วลบโฟลเดอร์ src/assets ทิ้งได้เพราะไม่ได้ใช้แล้ว

frontend/src/App.jsx

JSX
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { fetchProducts } from './features/products/productsSlice'
import './App.css'
 
export default function App() {
  const dispatch = useDispatch()
  const { items, status, error } = useSelector((state) => state.products)
 
  useEffect(() => {
    if (status === 'idle') dispatch(fetchProducts())
  }, [status, dispatch])
 
  return (
    <main className="shop">
      <h1>ร้านค้าของฉัน</h1>
      {status === 'loading' && <p>กำลังโหลด…</p>}
      {status === 'failed' && <p className="error">โหลดสินค้าไม่สำเร็จ: {error}</p>}
      <ul className="products">
        {items.map((p) => (
          <li key={p.id}>
            <span>{p.name}</span>
            <strong>{p.price.toLocaleString('th-TH')} บาท</strong>
          </li>
        ))}
      </ul>
    </main>
  )
}

frontend/src/App.css

CSS
.shop { max-width: 480px; margin: 3rem auto; padding: 0 1rem; text-align: left; }
.shop h1 { font-size: 2rem; margin-bottom: 1.5rem; }
.products { list-style: none; padding: 0; margin: 0; }
.products li { display: flex; justify-content: space-between; padding: .75rem 0; border-bottom: 1px solid #ddd; }
.error { color: #c0392b; }

frontend/src/index.css

CSS
:root { font-family: system-ui, 'Noto Sans Thai', sans-serif; color: #1a1a1a; background: #fafafa; }
body { margin: 0; }
ℹ️ยังไม่ต้องรัน npm run dev

ถ้ารันตอนนี้ หน้าเว็บจะขึ้นว่า "โหลดสินค้าไม่สำเร็จ" เพราะยังไม่มี API ซึ่งเป็นเรื่องปกติ เราจะรันทุกอย่างผ่าน Docker ในขั้นที่ 5


ขั้นที่ 1: จัดโครงสร้างโปรเจกต์และเตรียม API (15 นาที)

เป้าหมายของขั้นนี้คือโครงสร้างไฟล์แบบด้านล่าง ไฟล์ compose อยู่ที่ราก ส่วนแต่ละ service มีโฟลเดอร์และ Dockerfile ของตัวเอง

TEXT
myapp/
├── frontend/
│   ├── Dockerfile          ← ขั้นที่ 2
│   ├── .dockerignore       ← ขั้นที่ 2
│   ├── nginx.conf          ← ขั้นที่ 3
│   ├── vite.config.js      ← ขั้นที่ 7
│   ├── package.json
│   └── src/ ...
├── api/
│   ├── Dockerfile
│   ├── .dockerignore
│   ├── package.json
│   └── data/
│       └── db.json
├── docker-compose.yml           ← ขั้นที่ 4
└── docker-compose.override.yml  ← ขั้นที่ 8

1.1 ข้อมูลจำลอง db.json

json-server สร้าง REST API ให้จากคีย์ระดับบนสุดของไฟล์ JSON คีย์ products จะได้ GET /products, GET /products/:id, POST /products และอื่น ๆ ตามที่ระบุใน README ของ json-server

api/data/db.json

JSON
{
  "products": [
    { "id": "1", "name": "กาแฟดอยช้าง 250 ก.", "price": 250 },
    { "id": "2", "name": "ชาอู่หลงแม่โจ้", "price": 180 },
    { "id": "3", "name": "น้ำผึ้งดอกลำไย", "price": 320 }
  ]
}
ℹ️ทำไมต้องแยกโฟลเดอร์ data

เราจะ mount โฟลเดอร์ data เข้า container ไม่ใช่ mount ไฟล์ db.json เดี่ยว ๆ เพราะเมื่อมีการแก้ข้อมูล json-server จะเขียนไฟล์ใหม่ทับไฟล์เดิม การ mount ทั้งโฟลเดอร์จึงปลอดภัยกว่า (ทดสอบแล้วว่า POST ข้อมูลใหม่ผ่าน API แล้วไฟล์บนเครื่องเปลี่ยนตามจริง) และ json-server 1.0 จะเพิ่มบรรทัด "$schema" ลงในไฟล์เองหลังเขียนข้อมูล ซึ่งเป็นพฤติกรรมปกติ

1.2 ติดตั้ง json-server แบบระบุเวอร์ชัน

สร้างไฟล์ api/package.json โดยให้คำสั่ง start รัน json-server ที่ 0.0.0.0 (รับการเชื่อมต่อจาก container อื่น) บนพอร์ต 3001

api/package.json

JSON
{
  "name": "api",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "json-server data/db.json --host 0.0.0.0 --port 3001"
  }
}

จากนั้นติดตั้ง json-server แบบ ระบุเวอร์ชันตายตัว เพื่อให้ได้ package-lock.json สำหรับใช้ใน Dockerfile

POWERSHELL
cd ..\api
npm install --save-exact json-[email protected]-beta.15

npm จะเพิ่ม "dependencies": { "json-server": "1.0.0-beta.15" } ลงใน package.json (ไม่มี ^ นำหน้าเพราะใส่ --save-exact) และสร้างไฟล์ package-lock.json ขึ้นมา

ℹ️--host 0.0.0.0 จำเป็นไหม

สำหรับ json-server 1.0.0-beta.15 ไม่จำเป็น ทดสอบแล้วว่าเอาออกแล้ว web ก็ยังเรียกได้ เพราะซอร์สของรุ่นนี้เรียก app.listen(port) โดยไม่ส่ง host (node_modules/json-server/lib/bin.js บรรทัด 133) ค่า --host จึงมีผลแค่ URL ที่แสดงใน log แต่บทความใส่ไว้ตามสไลด์เพื่อให้เห็นชัดว่าตั้งใจรับการเชื่อมต่อจาก container อื่น หลักนี้ สำคัญจริง กับ Vite dev server ในขั้นที่ 2

1.3 Dockerfile ของ API

api/Dockerfile

DOCKERFILE
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3001
CMD ["npm", "start"]

api/.dockerignore

TEXT
node_modules

การ COPY package*.json แล้ว npm ci ก่อน COPY . . ทำให้ Docker ใช้ cache ของเลเยอร์ npm ci ซ้ำได้ ตราบใดที่ dependency ยังไม่เปลี่ยน และ .dockerignore ช่วยกันไม่ให้ node_modules บนเครื่อง (ซึ่งอาจ build มาสำหรับ Windows) ถูกคัดลอกเข้า image


ขั้นที่ 2: Dockerfile ของ frontend แบบ multi-stage (15 นาที)

ความต้องการของ frontend ในสองโหมดต่างกันมาก

  • prod: ต้องการเพียงไฟล์ HTML/JS/CSS ที่ build แล้ว เสิร์ฟด้วย Nginx ซึ่ง image เล็กและเร็ว
  • dev: ต้องการ Node, node_modules ครบ และ Vite dev server ที่คอยดูการแก้ไฟล์

Multi-stage build ช่วยให้เขียนทั้งสองแบบไว้ใน Dockerfile เดียว แล้วใช้ target ใน compose เลือกว่าจะ build ถึง stage ไหน

frontend/Dockerfile

DOCKERFILE
# ---------- stage "dev": Vite dev server (ใช้ตอนพัฒนา) ----------
FROM node:24-alpine AS dev
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
 
# ---------- stage "build": สร้างไฟล์ static ลง /app/dist ----------
FROM dev AS build
RUN npm run build
 
# ---------- stage "prod": Nginx เสิร์ฟไฟล์ที่ build แล้ว ----------
FROM nginx:1.31-alpine AS prod
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

frontend/.dockerignore

TEXT
node_modules
dist
Stageเริ่มจากได้อะไรใช้เมื่อ
devnode:24-alpineNode + node_modules + source, รัน vite --hosttarget: dev (override)
buildstage devไฟล์ static ใน /app/distใช้เป็นทางผ่าน ไม่ได้รันจริง
prodnginx:1.31-alpineNginx + nginx.conf + ไฟล์จาก build เท่านั้นtarget: prod (ไฟล์หลัก)
💡ทำไม vite ต้องมี --host

Vite dev server ฟังที่ localhost เป็นค่าเริ่มต้น เมื่ออยู่ใน container เครื่องของเราจะเข้าไม่ถึง การใส่ --host ทำให้ Vite ฟังทุก network interface ซึ่งเห็นได้จาก log ที่มีบรรทัด Network: http://172.18.0.3:5173/ เพิ่มขึ้นมา


ขั้นที่ 3: ตั้งค่า Nginx (10 นาที)

frontend/nginx.conf

NGINX
server {
  listen 80;
  root /usr/share/nginx/html;
 
  # ส่งต่อ /api/... ไปยัง service ชื่อ "api" (ตัด /api ออก)
  location /api/ {
    proxy_pass http://api:3001/;
  }
 
  # SPA fallback: ไม่เจอไฟล์ → ส่ง index.html
  location / {
    try_files $uri /index.html;
  }
}

มีรายละเอียดสามจุดที่ต้องเข้าใจ

  1. http://api:3001 ใช้ชื่อ service เป็นชื่อเครื่อง: Compose สร้าง network ให้ทุก service อัตโนมัติ และแต่ละ service ลงทะเบียนชื่อของตัวเองใน DNS ภายใน (Networking in Compose) พอร์ตที่ใช้เป็นพอร์ต ใน container (3001) ไม่ใช่พอร์ตที่ publish ออกมาบนเครื่อง
  2. / ท้าย proxy_pass ทำหน้าที่ตัด prefix: เอกสาร Nginx ระบุว่าถ้า proxy_pass มี URI ต่อท้าย ส่วนของ path ที่ตรงกับ location จะถูกแทนที่ด้วย URI นั้น ดังนั้น /api/products จะกลายเป็น /products ซึ่งตรงกับเส้นทางของ json-server ถ้าเขียน proxy_pass http://api:3001; (ไม่มี /) Nginx จะส่ง /api/products ไปทั้งก้อน แล้ว json-server จะตอบ 404
  3. try_files $uri /index.html คือ SPA fallback: try_files ลองหาไฟล์ตามลำดับ ถ้าไม่เจอจะ redirect ภายในไปยังพารามิเตอร์ตัวสุดท้าย ดังนั้นเมื่อผู้ใช้เปิด URL ลึก ๆ อย่าง /products/1 ตรง ๆ ก็ยังได้ index.html ให้ React Router จัดการต่อ แทนที่จะได้ 404

ขั้นที่ 4: เขียน docker-compose.yml หลัก (10 นาที)

ไฟล์หลักอธิบายระบบในแบบที่ ใกล้ production ที่สุด ไม่มีอะไรที่ใช้เฉพาะตอนพัฒนา

docker-compose.yml

YAML
services:
  web:
    build:
      context: ./frontend
      target: prod
    ports:
      - "8080:80"
    depends_on:
      - api
 
  api:
    build: ./api
    volumes:
      - ./api/data:/app/data
  • build.target: prod ให้ build ถึง stage prod ของ Dockerfile ในขั้นที่ 2
  • ports: "8080:80" เขียนในรูป เครื่องเรา:container เบราว์เซอร์จึงเปิด localhost:8080 เพื่อเข้าถึง Nginx ที่ฟังพอร์ต 80
  • depends_on กำหนด ลำดับการเริ่ม เท่านั้น เอกสาร Compose ระบุว่า short syntax ไม่ได้รอให้ service ปลายทาง "พร้อมใช้งาน" (healthy) ก่อน สำหรับแลบนี้ถือว่าเพียงพอ เพราะ json-server เริ่มทำงานในเวลาไม่ถึงวินาที
  • volumes: ./api/data:/app/data คือ bind mount ที่ทำให้ db.json อยู่บนเครื่องของเรา ลบ container ไปแล้วข้อมูลก็ยังอยู่
ℹ️docker-compose.yml หรือ compose.yaml?

เอกสาร Docker ปัจจุบันใช้ชื่อ compose.yaml และ compose.override.yaml ส่วนชื่อ docker-compose.yml / docker-compose.override.yml ที่ใช้ในบทความนี้ (ให้ตรงกับสไลด์) ยังรองรับอยู่ ทดสอบแล้วว่า Compose v5.5.1 อ่านไฟล์ override ชื่อนี้ให้อัตโนมัติ แม้ใช้ชื่อผสมกันก็ยังอ่านได้ แต่ควรเลือกใช้ชุดเดียวเพื่อไม่ให้สับสน


ขั้นที่ 5: รันสแต็กแบบ prod (15 นาที)

กลับไปที่โฟลเดอร์ myapp แล้วสั่งรันโดยระบุ เฉพาะไฟล์หลัก ด้วย -f (ตอนนี้ยังไม่มีไฟล์ override แต่ฝึกใส่ไว้ก่อน เพราะเมื่อมีไฟล์ override แล้ว คำสั่งนี้คือวิธีรันโหมด prod)

POWERSHELL
cd ..
docker compose -f docker-compose.yml up --build -d

ครั้งแรกจะใช้เวลาสักพักเพราะต้องดาวน์โหลด image และรัน npm ci ช่วงท้ายของ output ควรเป็นแบบนี้ (ตัดส่วน build ออก)

TEXT
 Image myapp-api Built
 Image myapp-web Built
 Network myapp_default Created
 Container myapp-api-1 Created
 Container myapp-web-1 Created
 Container myapp-api-1 Started
 Container myapp-web-1 Started

ชื่อ myapp-... มาจาก ชื่อโฟลเดอร์ ที่เรารันคำสั่ง (project name) และ network myapp_default คือ network ที่ Compose สร้างให้อัตโนมัติ ตรวจสถานะด้วยคำสั่ง

POWERSHELL
docker compose -f docker-compose.yml ps
TEXT
NAME          IMAGE       COMMAND                  SERVICE   CREATED         STATUS        PORTS
myapp-api-1   myapp-api   "docker-entrypoint.s…"   api       2 seconds ago   Up 1 second   3001/tcp
myapp-web-1   myapp-web   "/docker-entrypoint.…"   web       2 seconds ago   Up 1 second   0.0.0.0:8080->80/tcp, [::]:8080->80/tcp

สังเกตคอลัมน์ PORTS: web แสดง 0.0.0.0:8080->80/tcp (เปิดให้เครื่องเรา) ส่วน api แสดงแค่ 3001/tcp (ใช้ภายใน network เท่านั้น)

เปิด http://localhost:8080 ควรเห็นรายการสินค้าสามรายการ

หน้าเว็บร้านค้าของฉันที่ localhost:8080 แสดงสินค้าสามรายการพร้อมราคาเป็นบาท
รูปที่ 5.1 แอปที่เสิร์ฟด้วย Nginx ที่ localhost:8080

ตรวจว่า Nginx ส่งต่อ API ได้ถูกต้อง โดยเปิด http://localhost:8080/api/products ตรง ๆ ต้องได้ JSON จาก json-server

เบราว์เซอร์เปิด localhost:8080/api/products แสดงข้อมูล JSON ของสินค้าสามรายการ
รูปที่ 5.2 /api/products ที่ Nginx ส่งต่อไปยัง json-server
💡ทดสอบ SPA fallback

เปิด http://localhost:8080/some/deep/link ต้องยังเห็นหน้าร้านค้า (HTTP 200) ไม่ใช่หน้า 404 ของ Nginx นี่คือผลของ try_files ในขั้นที่ 3


ขั้นที่ 6: ปัญหาของโหมด prod ตอนพัฒนา (5 นาที)

ลองแก้ข้อความใน App.jsx แล้วบันทึก จากนั้น refresh localhost:8080 จะ ไม่มีอะไรเปลี่ยน เพราะ Nginx เสิร์ฟไฟล์ที่ build และคัดลอกเข้า image ไปแล้ว ถ้าอยากเห็นผลต้องสั่ง up --build ใหม่ทุกครั้ง

แผนภาพเปรียบเทียบสองวงจร วงจรโหมด prod ต้องสั่ง build image ใหม่ สร้าง container ใหม่ และ refresh เองโดย state หาย ส่วนวงจรโหมด dev แก้ไฟล์แล้ว bind mount ส่งไฟล์เข้า container และ Vite ส่ง HMR อัปเดตหน้าเว็บเองโดย state ยังอยู่
รูปที่ 6.1 วงจรการแก้โค้ดในโหมด prod เทียบกับโหมด dev + HMR

ตัวเลขจากเครื่องทดสอบ: แอปขนาดเล็กนี้ rebuild และสร้าง container ใหม่ใช้ ประมาณ 1.5–1.9 วินาที (มี cache ของ npm ci อยู่แล้ว) ฟังดูไม่นาน แต่ปัญหาจริงมีสามข้อ

  1. ต้องสั่งเองทุกครั้ง และเวลาที่ใช้จะเพิ่มตามขนาดโปรเจกต์ ถ้าแก้ package.json ก็ต้อง npm ci ใหม่ทั้งหมด
  2. ต้อง refresh เอง และ state ของหน้าหายหมด เช่น ฟอร์มที่กรอกค้างไว้ หรือข้อมูลใน Redux
  3. ไม่มี error overlay ของ Vite เวลาเขียนโค้ดผิด

ส่วนโหมด dev ที่จะทำต่อจากนี้ วัดได้ ประมาณ 180 มิลลิวินาที จากการบันทึกไฟล์จนหน้าเว็บเปลี่ยน โดยไม่ต้องสั่งอะไรและหน้าไม่ reload


ขั้นที่ 7: ตั้งค่า Vite ให้พร้อมทำงานใน container (10 นาที)

ในโหมด dev ไม่มี Nginx แล้ว Vite dev server ต้องทำสองอย่างแทน

frontend/vite.config.js

JS
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
 
// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    // Docker Desktop บน Windows: การแก้ไฟล์ผ่าน bind mount ไม่ส่งสัญญาณแจ้งเข้า container
    // จึงต้องให้ Vite ตรวจไฟล์เป็นรอบ ๆ (polling) — เปิดผ่าน environment ใน override
    watch: { usePolling: process.env.WATCH_POLLING === 'true' },
    proxy: {
      // /api/products → http://api:3001/products (เหมือนที่ Nginx ทำในโหมด prod)
      '/api': {
        target: process.env.API_URL ?? 'http://localhost:3001',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
})

7.1 server.proxy ทำหน้าที่แทน Nginx

server.proxy ส่งต่อ request ที่ขึ้นต้นด้วย /api ไปยัง target และ rewrite ตัด /api ออกให้เหมือนกับที่ / ท้าย proxy_pass ทำในขั้นที่ 3 ผลคือโค้ด React เรียก /api/products ได้เหมือนกันทั้งสองโหมด

target อ่านค่าจาก environment API_URL ซึ่ง override จะตั้งเป็น http://api:3001 ถ้าไม่มีค่านี้จะใช้ http://localhost:3001 แทน กรณีที่รัน npm run dev บนเครื่องโดยตรงโดยไม่ผ่าน Docker

7.2 server.watch.usePolling ทำให้ HMR ทำงานบน Windows

เอกสาร Vite ระบุว่าเมื่อใช้ Docker ที่มี WSL2 เป็น backend แล้วแก้ไฟล์จากโปรแกรมฝั่ง Windows การตรวจจับการแก้ไฟล์จะไม่ทำงาน วิธีแก้ที่เอกสารให้ไว้คือ usePolling: true ซึ่งแลกกับการใช้ CPU มากขึ้น ตรวจได้ว่า Docker ของเราใช้ WSL2 หรือไม่ด้วย docker info ถ้าบรรทัด Kernel Version ลงท้ายด้วย WSL2 (เครื่องทดสอบได้ 6.6.87.2-microsoft-standard-WSL2+) แสดงว่าต้องเปิด polling

ผลทดสอบบนเครื่องจริง:

การตั้งค่าผลเมื่อแก้ App.jsx แล้วบันทึก
ไม่เปิด pollingไฟล์ใน container เปลี่ยนแล้ว แต่ Vite ไม่รู้ตัว รอ 20 วินาทีหน้าเว็บก็ไม่เปลี่ยน
WATCH_POLLING: "true"log แสดง [vite] (client) hmr update /src/App.jsx หน้าเว็บเปลี่ยนภายใน ~180 ms
ℹ️แล้ว CHOKIDAR_USEPOLLING=true ในสไลด์ล่ะ?

ใช้ได้เช่นกัน ตัวแปรนี้เป็นความสามารถของไลบรารี chokidar ที่ Vite 8.3 ใช้ดูไฟล์ (ตรวจแล้วว่าซอร์สของ Vite ที่ติดตั้งอ่าน process.env.CHOKIDAR_USEPOLLING) แต่ เอกสารของ Vite ไม่ได้กล่าวถึงตัวแปรนี้ และระบุว่าเมื่อเปิดโหมด bundled-dev ตัวดูไฟล์ของ Rolldown จะรับค่า usePolling ผ่าน server.watch บทความนี้จึงเลือกตั้งค่าผ่าน server.watch ตามเอกสารของ Vite


ขั้นที่ 8: เขียน docker-compose.override.yml (10 นาที)

docker-compose.override.yml

YAML
# dev override: docker compose up (ไม่ใส่ -f) จะรวมไฟล์นี้อัตโนมัติ
services:
  web:
    build:
      target: dev
    ports: !override
      - "5173:5173"
    environment:
      API_URL: http://api:3001
      WATCH_POLLING: "true"
    volumes:
      - ./frontend:/app
      - /app/node_modules

ไฟล์นี้ ไม่ได้เขียน service ใหม่ แต่เขียนเฉพาะส่วนที่ต่างจากไฟล์หลัก Compose จะนำไปรวมกับ docker-compose.yml ตามกฎในเอกสาร Merge

ตารางสามคอลัมน์แสดงค่าของ service web ในไฟล์หลัก ไฟล์ override และผลที่รวมแล้ว build target จาก prod เป็น dev, ports ถูกแทนที่ด้วย 5173:5173 เพราะ !override, environment และ volumes ถูกเพิ่มเข้าไป
รูปที่ 8.1 ไฟล์หลัก + override รวมกันเป็น dev stack
บรรทัดทำอะไรกฎการรวม
build.target: devbuild ถึง stage dev (Vite) แทน prod (Nginx)ค่าเดี่ยว ค่าใหม่ แทนที่ ค่าเดิม
ports: !overrideเหลือแค่ 5173:5173ปกติ Compose จะ ต่อท้าย list ของพอร์ต ได้เป็น 8080:80 และ 5173:5173 ทั้งที่ dev server ไม่ได้ฟังพอร์ต 80 แท็ก !override สั่งให้ แทนทั้งชุด
environmentส่ง API_URL และ WATCH_POLLING ให้ vite.config.jsmap รวมกัน
./frontend:/appbind mount โฟลเดอร์โค้ดบนเครื่องเข้าไปแทน /app ใน container แก้ไฟล์บนเครื่องแล้วไฟล์ใน container เปลี่ยนตามทันทีvolumes รวมกันตาม path ปลายทาง
/app/node_modulesanonymous volume กันไม่ให้ bind mount บรรทัดบนทับ node_modules ที่ npm ci ติดตั้งไว้ใน imageเช่นเดียวกัน
📌ทำไม /app/node_modules ถึงไม่ว่าง

เอกสาร Docker volumes อธิบายว่าเมื่อ mount volume ว่าง ลงบนโฟลเดอร์ใน container ที่มีไฟล์อยู่แล้ว Docker จะ คัดลอกไฟล์เดิมเข้า volume ให้ ครั้งแรกที่สร้าง container, anonymous volume นี้จึงได้ node_modules ที่ npm ci ติดตั้งไว้ใน stage dev ต่างจากสไลด์ที่ใช้ image node:20-alpine เปล่า ๆ ซึ่งไม่มี node_modules ให้คัดลอก จึงเกิด sh: vite: not found

ดูผลการรวมไฟล์จริงก่อนรันได้ด้วยคำสั่ง config ซึ่งเหมาะมากไว้ debug ว่า override มีผลอย่างที่คิดหรือไม่

POWERSHELL
docker compose config

ขั้นที่ 9: รันแบบ dev และทดสอบ Hot Reload (15 นาที)

หยุดสแต็ก prod ก่อน แล้วรันใหม่ โดยไม่ใส่ -f Compose จะอ่าน docker-compose.yml และ docker-compose.override.yml แล้วรวมกันให้อัตโนมัติ

POWERSHELL
docker compose down
docker compose up --build -d
docker compose ps
TEXT
NAME          IMAGE       COMMAND                  SERVICE   CREATED          STATUS         PORTS
myapp-api-1   myapp-api   "docker-entrypoint.s…"   api       12 seconds ago   Up 9 seconds   3001/tcp
myapp-web-1   myapp-web   "docker-entrypoint.s…"   web       10 seconds ago   Up 8 seconds   0.0.0.0:5173->5173/tcp, [::]:5173->5173/tcp

web ตอนนี้เปิดแค่พอร์ต 5173 (ผลของ !override) ดู log ของ Vite ด้วยคำสั่ง

POWERSHELL
docker compose logs -f web
TEXT
web-1  | > [email protected] dev
web-1  | > vite --host
web-1  |
web-1  |   VITE v8.3.0  ready in 371 ms
web-1  |
web-1  |   ➜  Local:   http://localhost:5173/
web-1  |   ➜  Network: http://172.18.0.3:5173/  eth0

เปิด http://localhost:5173 ต้องเห็นรายการสินค้าเหมือนโหมด prod ถ้าเห็น แสดงว่า Vite proxy ส่ง /api ต่อไปยัง api ได้แล้ว

ทดสอบ HMR: เปิดเบราว์เซอร์กับเทอร์มินัลที่รัน logs -f ไว้คู่กัน แก้หัวข้อใน frontend/src/App.jsx แล้วบันทึก

JSX
      <h1>ร้านค้าของฉัน — ลดราคา 10%</h1>

หน้าเว็บจะเปลี่ยนเองทันทีโดยไม่ต้องกด refresh และ log แสดงบรรทัดนี้เพิ่มขึ้น

TEXT
web-1  | 3:30:07 PM [vite] (client) hmr update /src/App.jsx
ภาพเบราว์เซอร์สองจอเทียบกันที่ localhost:5173 ด้านซ้ายหัวข้อคือร้านค้าของฉัน ด้านขวาหลังบันทึกไฟล์หัวข้อเปลี่ยนเป็นร้านค้าของฉัน ลดราคา 10% โดยรายการสินค้ายังอยู่ครบ
รูปที่ 9.1 แก้ App.jsx แล้วหน้าเว็บอัปเดตเองด้วย HMR
💡พิสูจน์ว่าหน้าไม่ได้ reload

เปิด DevTools (F12) แท็บ Console แล้วพิมพ์ window.x = 1 จากนั้นแก้ไฟล์และบันทึก แล้วพิมพ์ window.x อีกครั้ง ถ้ายังได้ 1 แสดงว่าเป็น HMR จริง ไม่ใช่การโหลดหน้าใหม่ (ถ้า reload ตัวแปรจะหายไป) Console จะแสดง [vite] hot updated: /src/App.jsx ด้วย

🚨สลับโหมดเมื่อไร ใส่ --build ทุกครั้ง

image ของ web ทั้งสองโหมดชื่อเดียวกัน (myapp-web) ทดสอบแล้วว่าหลังรันโหมด dev ถ้ากลับไปสั่ง docker compose -f docker-compose.yml up -d โดยไม่ใส่ --build Compose จะใช้ image ของ dev ที่ build ไว้ล่าสุด พอร์ต 8080 จะชี้ไปที่ container ที่ไม่มี Nginx และเบราว์เซอร์ขึ้น ERR_EMPTY_RESPONSE วิธีแก้คือใส่ --build ทุกครั้งที่สลับโหมด


ขั้นเสริม: ตรวจสอบสแต็กด้วย Docker Desktop (15 นาที)

ขั้นนี้เชื่อมกับหัวข้อ Docker Desktop ในบทเรียน เป้าหมายคือ พิสูจน์ สามเรื่องที่บทความอ้างไว้ด้วยตาตัวเอง โดยให้สแต็กโหมด dev รันค้างไว้

ℹ️เกี่ยวกับภาพหน้าจอในขั้นนี้

สามภาพต่อไปนี้ถ่ายจากเครื่องผู้เขียนตอนทดสอบ ซึ่งใช้ชื่อโฟลเดอร์โปรเจกต์ว่า products-app container จึงชื่อ products-app-web-1 / api-1 และ Vite เป็นรุ่นก่อนหน้าเล็กน้อย ถ้าคุณทำตามบทความมาตั้งแต่ต้น ชื่อที่เห็นบนเครื่องคุณจะเป็น myapp / myapp-web-1 / myapp-api-1 ส่วนที่เหลือเหมือนกันทุกอย่าง

เรื่องที่ 1: สอง container อยู่ใน project เดียวกัน

เปิด Docker Desktop แล้วเลือก Containers จะเห็นกลุ่ม myapp ที่มี web และ api อยู่ข้างใน คลิกพอร์ต 5173:5173 เพื่อเปิดแอปในเบราว์เซอร์ได้เลย

หน้า Containers ของ Docker Desktop แสดงกลุ่มโปรเจกต์หนึ่งกลุ่มที่มี container web และ api สถานะ Running และพอร์ต 5173:5173
รูปที่ 10.1 container ทั้งสองอยู่ใต้ project เดียวกันในหน้า Containers

เรื่องที่ 2: log ของ HMR

คลิก container myapp-web-1 แล้วเลือกแท็บ Logs (Explore the Containers view) จากนั้นแก้ App.jsx อีกครั้ง จะเห็นบรรทัด hmr update เพิ่มขึ้นแบบ real time เหมือน docker compose logs -f web

แท็บ Logs ของ container web ใน Docker Desktop แสดงบรรทัด VITE ready และ hmr update /src/App.jsx สองบรรทัด
รูปที่ 10.2 แท็บ Logs ของ container web แสดง hmr update

เรื่องที่ 3: เรียก service ด้วยชื่อ และ bind mount คือไฟล์เดียวกัน

ในหน้าเดียวกันเลือกแท็บ Exec (เทียบเท่าคำสั่ง docker exec -it ... /bin/sh) แล้วพิมพ์คำสั่งทีละบรรทัด หรือจะรันจากเทอร์มินัลบนเครื่องก็ได้ ผลลัพธ์เหมือนกัน

POWERSHELL
docker compose exec web wget -qO- http://api:3001/products/1
docker compose exec web ls /app/src
TEXT
{
  "id": "1",
  "name": "กาแฟดอยช้าง 250 ก.",
  "price": 250
}
App.css
App.jsx
features
index.css
main.jsx
store.js

บรรทัดแรกพิสูจน์ว่า web เรียก api ด้วยชื่อ service ได้ (DNS ของ Compose) บรรทัดที่สองแสดงว่าไฟล์ใน /app/src ของ container คือไฟล์ชุดเดียวกับ frontend/src บนเครื่อง (bind mount)

แท็บ Exec ของ container web ใน Docker Desktop ที่รันคำสั่ง wget ไปยัง api:3001/products/1 ได้ JSON กลับมา และ ls /app/src เห็นไฟล์ชุดเดียวกับบนเครื่อง
รูปที่ 10.3 เรียก api ด้วยชื่อ service จากแท็บ Exec
ℹ️ใช้ Git Bash บน Windows?

Git Bash จะแปลง path ที่ขึ้นต้นด้วย / เช่น /app/src เป็น path ของ Windows ให้อัตโนมัติ ทำให้ docker compose exec web ls /app/src ขึ้น error ว่าหาไฟล์ไม่เจอ ให้ใช้ PowerShell หรือพิมพ์ export MSYS_NO_PATHCONV=1 ก่อน


เก็บกวาดหลังทำแลบ (2 นาที)

container ที่เปิดทิ้งไว้จะจองพอร์ต 5173/8080 และทำให้แลบถัดไปรันไม่ขึ้น เมื่อทำเสร็จแล้วให้สั่ง

POWERSHELL
docker compose down -v

docker compose down ลบ container และ network ของ project ส่วน -v ลบ anonymous volume (/app/node_modules) ด้วย ถ้าไม่ใส่ -v volume นี้จะค้างอยู่ในเครื่อง ข้อมูลใน api/data/db.json ไม่หาย เพราะเป็น bind mount ไปยังไฟล์บนเครื่องเรา ไม่ใช่ volume ของ Docker


Checklist สิ่งที่ต้องส่ง (รายวิชา 10306493)

📌สำหรับนักศึกษา
  1. docker-compose.yml (web + api) ที่รันแบบ prod-like ได้ด้วย docker compose -f docker-compose.yml up --build
  2. nginx.conf ที่เสิร์ฟ build, proxy /api และมี SPA fallback
  3. docker-compose.override.yml ที่เปิด hot reload
  4. ภาพหน้าจอ docker compose ps ที่แสดงสองบริการกำลังรัน (ทั้งโหมด prod และ dev)
  5. วิดีโอหรือภาพก่อน/หลัง: แก้โค้ดแล้วเบราว์เซอร์อัปเดตทันที (HMR)
  6. คำอธิบายสั้น ๆ ว่าไฟล์ base กับ override ต่างกันอย่างไร และกฎการรวมที่ใช้มีอะไรบ้าง (ใช้ตารางในขั้นที่ 8 เป็นแนวทาง)

ทดสอบความเข้าใจ


อยากไปต่อ? ลองทำโจทย์ท้าทาย

  1. เพิ่ม healthcheck ให้ api แล้วเปลี่ยน depends_on ของ web เป็น condition: service_healthy
  2. ลองใช้ docker compose watch แทน bind mount แล้วเปรียบเทียบว่าต่างกันอย่างไร
  3. เพิ่มหน้า "เพิ่มสินค้า" ที่ POST /api/products แล้วตรวจว่า api/data/db.json บนเครื่องเปลี่ยนตาม

สรุป

  • ไฟล์ หลัก อธิบายระบบแบบใกล้ production ส่วน override เพิ่มความสะดวกตอนพัฒนา โดยไม่ต้องแก้ไฟล์หลัก
  • docker compose up รวมสองไฟล์ให้อัตโนมัติ ส่วน docker compose -f docker-compose.yml up ใช้ไฟล์หลักอย่างเดียว
  • Dockerfile แบบ multi-stage + target ทำให้ใช้ Dockerfile เดียวได้ทั้ง dev และ prod
  • โค้ด frontend เรียก /api แบบ relative ส่วน Nginx (prod) หรือ Vite proxy (dev) รับหน้าที่ส่งต่อให้ ทำให้โค้ดเหมือนกันทั้งสองโหมด
  • บน Windows ต้องเปิด polling ไม่อย่างนั้น HMR จะไม่ทำงาน
  • ทุกครั้งที่สลับโหมดให้ใส่ --build และเมื่อทำแลบเสร็จให้สั่ง docker compose down -v

ภาคผนวก: ปัญหาที่พบบ่อยและวิธีแก้

อาการสาเหตุวิธีแก้
web จบการทำงานด้วย Exited (127), log แสดง sh: vite: not found/app/node_modules ว่าง (เช่นใช้ image Node เปล่าแบบในสไลด์)ใช้ build.target: dev ตามขั้นที่ 2 และ 8
แก้โค้ดแล้วหน้าเว็บไม่เปลี่ยน (โหมด dev)ไม่ได้เปิด polling บน Windowsตรวจ WATCH_POLLING: "true" ใน override และ server.watch ใน vite.config.js
แก้โค้ดแล้วหน้าเว็บไม่เปลี่ยน แม้เปิด polling แล้วpath ของ bind mount ผิดรัน docker compose exec web ls /app/src ต้องเห็นไฟล์ของเรา
หน้าเว็บขึ้น "โหลดสินค้าไม่สำเร็จ" ที่พอร์ต 5173ไม่ได้ตั้ง server.proxy หรือ API_URLตรวจขั้นที่ 7 และดูผลจาก docker compose config
/api/products ได้ 404 ที่พอร์ต 8080proxy_pass ไม่มี / ท้าย หรือชื่อ service ไม่ตรงตรวจขั้นที่ 3
/api/... ค้างนานประมาณ 60 วินาทีแล้วได้ 504 Gateway Time-outcontainer api หยุดทำงาน (ทดสอบด้วย docker compose stop api)docker compose ps -a และ docker compose logs api ดูว่า api หยุดเพราะอะไร
localhost:8080 ขึ้น ERR_EMPTY_RESPONSEสลับจาก dev มา prod โดยไม่ใส่ --builddocker compose -f docker-compose.yml up --build -d
เพิ่ม package ใหม่แล้วขึ้น error หา module ไม่เจอanonymous volume เก็บ node_modules ชุดเก่าไว้ (ทดสอบแล้วว่า up --build อย่างเดียวไม่พอ)docker compose up --build -d -V (-V = --renew-anon-volumes)
ports are not available: exposing port TCP 0.0.0.0:8080 ... bind: Only one usage of each socket address ... (ข้อความบน Windows)มีโปรแกรมอื่นใช้พอร์ตอยู่ เช่น npm run dev บนเครื่อง หรือสแต็กจากแลบอื่น (api อาจ start ได้ แต่ web ไม่ขึ้น)ปิดโปรแกรมนั้น หรือเปลี่ยนพอร์ตฝั่งซ้าย เช่น "8081:80"
override ไม่มีผล (เปิด 5173 ไม่ได้)สั่งด้วย -f docker-compose.yml ซึ่งจะอ่านเฉพาะไฟล์ที่ระบุรัน docker compose up --build -d โดยไม่ใส่ -f แล้วตรวจด้วย docker compose config

แหล่งอ้างอิง (ตรวจสอบเมื่อ 22 กันยายน 2569)

Docker Compose

Docker

Nginx

Vite / React / Redux

json-server และ Node.js

By Watcharin Sarachai