Part of 10306493 การศึกษาหัวข้อสนใจด้านเทคโนโลยีสารสนเทศ (Selected Topic in Information Technology)
Lab: รัน Full Stack ในเครื่องด้วย Docker Compose พร้อม Hot Reload (React + Vite + Nginx + json-server)
แอปจริงแทบไม่เคยมีแค่ container เดียว หน้าเว็บ React ต้องคุยกับ API และในระบบจริงก็มักมี Nginx คั่นอยู่ตรงกลาง บทความนี้พาเขียน Docker Compose ที่รันทั้งสามส่วนด้วยคำสั่งเดียว แล้วเพิ่มไฟล์ override ให้แก้โค้ดแล้วเห็นผลในเบราว์เซอร์ทันที (Hot Reload) โดยไม่ต้อง build image ใหม่
ทุกไฟล์ในบทความนี้ รันทดสอบจริงแล้ว ผลลัพธ์ในกรอบ output และตัวเลขเวลาที่อ้างถึงเป็นค่าที่วัดได้จากเครื่องทดสอบ ไม่ได้ประมาณเอา
สิ่งที่คุณจะได้เรียนรู้
- เขียน
docker-compose.ymlที่รัน frontend (Nginx) คู่กับ JSON API และให้ service เรียกกันด้วยชื่อ - เขียน Dockerfile แบบ multi-stage ไฟล์เดียวที่สร้างได้ทั้ง image สำหรับ dev และ prod
- ตั้งค่า Nginx ให้เสิร์ฟ SPA และส่งต่อ
/apiไปยัง API (reverse proxy) - ใช้
docker-compose.override.ymlเปลี่ยนสแต็กเดิมเป็นโหมดพัฒนาที่มี HMR โดยไม่ต้องแก้ไฟล์หลัก - เข้าใจ กฎการรวมไฟล์ ของ Compose (แทนที่ / ต่อท้าย /
!override) - ตรวจสอบและแก้ปัญหาสแต็กด้วย 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
ไฟล์ในสไลด์ใช้อธิบายแนวคิดได้ดี แต่เมื่อรันตามทุกตัวอักษรบน Docker Compose v5.5 จะติดปัญหา บทความนี้จึงแก้ไว้ดังนี้
- Dockerfile แบบ multi-stage แทน
image: node:20-alpineใน override: เมื่อรันตามสไลด์ servicewebจบการทำงานทันทีด้วยExited (127)และ log แสดงsh: vite: not foundเพราะ volume/app/node_modulesว่างและไม่มีใครสั่งnpm installบทความนี้ให้ image ของ dev ติดตั้ง dependency ไว้ตั้งแต่ตอน build แล้วเลือกด้วยtarget - Node 24 แทน Node 20: Node 20 หมดระยะสนับสนุน (End-of-Life) ไปแล้วเมื่อ 30 เมษายน 2026 ตามตาราง release ของ Node.js ปัจจุบัน Node 24 เป็นรุ่น LTS
- ตั้ง proxy ใน
vite.config.js: ในโหมด dev ไม่มี Nginx คอยส่งต่อ/apiถ้าไม่ตั้ง proxy แอปจะดึงข้อมูลสินค้าไม่ได้ - เปิด polling ให้ Vite: บน Windows ถ้าไม่เปิด polling แล้วแก้ไฟล์ Vite จะไม่รู้ตัวเลย (ทดสอบรอ 20 วินาทีแล้วหน้าเว็บก็ไม่เปลี่ยน) ในสไลด์ข้อนี้อยู่แค่ในหน้า Troubleshooting บทความนี้จึงให้เป็นขั้นตอนหลัก
- pin เวอร์ชัน json-server: ในสไลด์
npx json-serverบน Node 20 ได้รุ่น 0.17.4 เพราะรุ่นล่าสุด 1.0.0-beta.15 ต้องใช้ Node ≥ 22.12 ถ้าไม่ pin ไว้ เวอร์ชันที่ได้จะขึ้นกับ Node ที่ใช้อยู่ บทความนี้จึงติดตั้ง json-server เป็น dependency ที่ระบุเวอร์ชันชัดเจน
ภาพรวมระบบที่จะสร้าง
| Service | Image | หน้าที่ | พอร์ต |
|---|---|---|---|
web | build จาก frontend/Dockerfile (stage prod = Nginx) | เสิร์ฟไฟล์ React ที่ build แล้ว และส่งต่อ /api/ ไปยัง api | เปิดให้เครื่องเราที่ 8080 |
api | build จาก api/Dockerfile (Node 24 + json-server) | REST API จำลองจากไฟล์ db.json | 3001 ใช้กันภายใน network เท่านั้น |
ประเด็นสำคัญของภาพนี้คือ เบราว์เซอร์คุยกับ localhost:8080 ที่เดียว ทั้งหน้าเว็บและ /api มาจาก origin เดียวกัน จึงไม่ต้องตั้งค่า CORS ส่วน api ไม่ได้ publish พอร์ตออกมานอก network เพราะมีแค่ web ที่ต้องเรียกใช้
สิ่งที่ต้องเตรียมก่อนเริ่ม
- Docker Desktop ที่รันอยู่ ตรวจด้วยคำสั่งด้านล่าง (ต้องได้เวอร์ชันของ Server และ Compose ไม่ใช่ error)
docker version --format "{{.Server.Version}}"
docker compose version- Node.js 22 ขึ้นไปบนเครื่อง ใช้สร้างโปรเจกต์และสร้างไฟล์
package-lock.jsonส่วนตอนรันจริงใช้ Node ใน container - พอร์ต 8080 และ 5173 ต้องว่าง (ถ้าเปิด
npm run devค้างไว้จากแลบก่อน ให้ปิดก่อน) - โปรแกรมแก้โค้ด เช่น 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
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
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.reducerfrontend/src/store.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
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
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
.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
:root { font-family: system-ui, 'Noto Sans Thai', sans-serif; color: #1a1a1a; background: #fafafa; }
body { margin: 0; }ถ้ารันตอนนี้ หน้าเว็บจะขึ้นว่า "โหลดสินค้าไม่สำเร็จ" เพราะยังไม่มี API ซึ่งเป็นเรื่องปกติ เราจะรันทุกอย่างผ่าน Docker ในขั้นที่ 5
ขั้นที่ 1: จัดโครงสร้างโปรเจกต์และเตรียม API (15 นาที)
เป้าหมายของขั้นนี้คือโครงสร้างไฟล์แบบด้านล่าง ไฟล์ compose อยู่ที่ราก ส่วนแต่ละ service มีโฟลเดอร์และ Dockerfile ของตัวเอง
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 ← ขั้นที่ 81.1 ข้อมูลจำลอง db.json
json-server สร้าง REST API ให้จากคีย์ระดับบนสุดของไฟล์ JSON คีย์ products จะได้ GET /products, GET /products/:id, POST /products และอื่น ๆ ตามที่ระบุใน README ของ json-server
api/data/db.json
{
"products": [
{ "id": "1", "name": "กาแฟดอยช้าง 250 ก.", "price": 250 },
{ "id": "2", "name": "ชาอู่หลงแม่โจ้", "price": 180 },
{ "id": "3", "name": "น้ำผึ้งดอกลำไย", "price": 320 }
]
}เราจะ 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
{
"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
cd ..\api
npm install --save-exact json-[email protected]-beta.15npm จะเพิ่ม "dependencies": { "json-server": "1.0.0-beta.15" } ลงใน package.json (ไม่มี ^ นำหน้าเพราะใส่ --save-exact) และสร้างไฟล์ package-lock.json ขึ้นมา
สำหรับ 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
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3001
CMD ["npm", "start"]api/.dockerignore
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
# ---------- 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 80frontend/.dockerignore
node_modules
dist| Stage | เริ่มจาก | ได้อะไร | ใช้เมื่อ |
|---|---|---|---|
dev | node:24-alpine | Node + node_modules + source, รัน vite --host | target: dev (override) |
build | stage dev | ไฟล์ static ใน /app/dist | ใช้เป็นทางผ่าน ไม่ได้รันจริง |
prod | nginx:1.31-alpine | Nginx + nginx.conf + ไฟล์จาก build เท่านั้น | target: prod (ไฟล์หลัก) |
Vite dev server ฟังที่ localhost เป็นค่าเริ่มต้น เมื่ออยู่ใน container เครื่องของเราจะเข้าไม่ถึง การใส่ --host ทำให้ Vite ฟังทุก network interface ซึ่งเห็นได้จาก log ที่มีบรรทัด Network: http://172.18.0.3:5173/ เพิ่มขึ้นมา
ขั้นที่ 3: ตั้งค่า Nginx (10 นาที)
frontend/nginx.conf
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;
}
}มีรายละเอียดสามจุดที่ต้องเข้าใจ
http://api:3001ใช้ชื่อ service เป็นชื่อเครื่อง: Compose สร้าง network ให้ทุก service อัตโนมัติ และแต่ละ service ลงทะเบียนชื่อของตัวเองใน DNS ภายใน (Networking in Compose) พอร์ตที่ใช้เป็นพอร์ต ใน container (3001) ไม่ใช่พอร์ตที่ publish ออกมาบนเครื่อง/ท้าย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 จะตอบ 404try_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
services:
web:
build:
context: ./frontend
target: prod
ports:
- "8080:80"
depends_on:
- api
api:
build: ./api
volumes:
- ./api/data:/app/databuild.target: prodให้ build ถึง stageprodของ Dockerfile ในขั้นที่ 2ports: "8080:80"เขียนในรูปเครื่องเรา:containerเบราว์เซอร์จึงเปิดlocalhost:8080เพื่อเข้าถึง Nginx ที่ฟังพอร์ต 80depends_onกำหนด ลำดับการเริ่ม เท่านั้น เอกสาร Compose ระบุว่า short syntax ไม่ได้รอให้ service ปลายทาง "พร้อมใช้งาน" (healthy) ก่อน สำหรับแลบนี้ถือว่าเพียงพอ เพราะ json-server เริ่มทำงานในเวลาไม่ถึงวินาทีvolumes: ./api/data:/app/dataคือ bind mount ที่ทำให้db.jsonอยู่บนเครื่องของเรา ลบ container ไปแล้วข้อมูลก็ยังอยู่
เอกสาร 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)
cd ..
docker compose -f docker-compose.yml up --build -dครั้งแรกจะใช้เวลาสักพักเพราะต้องดาวน์โหลด image และรัน npm ci ช่วงท้ายของ output ควรเป็นแบบนี้ (ตัดส่วน build ออก)
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 สร้างให้อัตโนมัติ ตรวจสถานะด้วยคำสั่ง
docker compose -f docker-compose.yml psNAME 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 ควรเห็นรายการสินค้าสามรายการ
ตรวจว่า Nginx ส่งต่อ API ได้ถูกต้อง โดยเปิด http://localhost:8080/api/products ตรง ๆ ต้องได้ JSON จาก json-server
เปิด 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 ใหม่ทุกครั้ง
ตัวเลขจากเครื่องทดสอบ: แอปขนาดเล็กนี้ rebuild และสร้าง container ใหม่ใช้ ประมาณ 1.5–1.9 วินาที (มี cache ของ npm ci อยู่แล้ว) ฟังดูไม่นาน แต่ปัญหาจริงมีสามข้อ
- ต้องสั่งเองทุกครั้ง และเวลาที่ใช้จะเพิ่มตามขนาดโปรเจกต์ ถ้าแก้
package.jsonก็ต้องnpm ciใหม่ทั้งหมด - ต้อง refresh เอง และ state ของหน้าหายหมด เช่น ฟอร์มที่กรอกค้างไว้ หรือข้อมูลใน Redux
- ไม่มี error overlay ของ Vite เวลาเขียนโค้ดผิด
ส่วนโหมด dev ที่จะทำต่อจากนี้ วัดได้ ประมาณ 180 มิลลิวินาที จากการบันทึกไฟล์จนหน้าเว็บเปลี่ยน โดยไม่ต้องสั่งอะไรและหน้าไม่ reload
ขั้นที่ 7: ตั้งค่า Vite ให้พร้อมทำงานใน container (10 นาที)
ในโหมด dev ไม่มี Nginx แล้ว Vite dev server ต้องทำสองอย่างแทน
frontend/vite.config.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 ที่ 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
# 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
| บรรทัด | ทำอะไร | กฎการรวม |
|---|---|---|
build.target: dev | build ถึง 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.js | map รวมกัน |
./frontend:/app | bind mount โฟลเดอร์โค้ดบนเครื่องเข้าไปแทน /app ใน container แก้ไฟล์บนเครื่องแล้วไฟล์ใน container เปลี่ยนตามทันที | volumes รวมกันตาม path ปลายทาง |
/app/node_modules | anonymous volume กันไม่ให้ bind mount บรรทัดบนทับ node_modules ที่ npm ci ติดตั้งไว้ใน image | เช่นเดียวกัน |
เอกสาร 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 มีผลอย่างที่คิดหรือไม่
docker compose configขั้นที่ 9: รันแบบ dev และทดสอบ Hot Reload (15 นาที)
หยุดสแต็ก prod ก่อน แล้วรันใหม่ โดยไม่ใส่ -f Compose จะอ่าน docker-compose.yml และ docker-compose.override.yml แล้วรวมกันให้อัตโนมัติ
docker compose down
docker compose up --build -d
docker compose psNAME 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/tcpweb ตอนนี้เปิดแค่พอร์ต 5173 (ผลของ !override) ดู log ของ Vite ด้วยคำสั่ง
docker compose logs -f webweb-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 แล้วบันทึก
<h1>ร้านค้าของฉัน — ลดราคา 10%</h1>หน้าเว็บจะเปลี่ยนเองทันทีโดยไม่ต้องกด refresh และ log แสดงบรรทัดนี้เพิ่มขึ้น
web-1 | 3:30:07 PM [vite] (client) hmr update /src/App.jsxเปิด DevTools (F12) แท็บ Console แล้วพิมพ์ window.x = 1 จากนั้นแก้ไฟล์และบันทึก แล้วพิมพ์ window.x อีกครั้ง ถ้ายังได้ 1 แสดงว่าเป็น HMR จริง ไม่ใช่การโหลดหน้าใหม่ (ถ้า reload ตัวแปรจะหายไป) Console จะแสดง [vite] hot updated: /src/App.jsx ด้วย
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 เพื่อเปิดแอปในเบราว์เซอร์ได้เลย
เรื่องที่ 2: log ของ HMR
คลิก container myapp-web-1 แล้วเลือกแท็บ Logs (Explore the Containers view) จากนั้นแก้ App.jsx อีกครั้ง จะเห็นบรรทัด hmr update เพิ่มขึ้นแบบ real time เหมือน docker compose logs -f web
เรื่องที่ 3: เรียก service ด้วยชื่อ และ bind mount คือไฟล์เดียวกัน
ในหน้าเดียวกันเลือกแท็บ Exec (เทียบเท่าคำสั่ง docker exec -it ... /bin/sh) แล้วพิมพ์คำสั่งทีละบรรทัด หรือจะรันจากเทอร์มินัลบนเครื่องก็ได้ ผลลัพธ์เหมือนกัน
docker compose exec web wget -qO- http://api:3001/products/1
docker compose exec web ls /app/src{
"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)
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 และทำให้แลบถัดไปรันไม่ขึ้น เมื่อทำเสร็จแล้วให้สั่ง
docker compose down -vdocker compose down ลบ container และ network ของ project ส่วน -v ลบ anonymous volume (/app/node_modules) ด้วย ถ้าไม่ใส่ -v volume นี้จะค้างอยู่ในเครื่อง ข้อมูลใน api/data/db.json ไม่หาย เพราะเป็น bind mount ไปยังไฟล์บนเครื่องเรา ไม่ใช่ volume ของ Docker
Checklist สิ่งที่ต้องส่ง (รายวิชา 10306493)
docker-compose.yml(web + api) ที่รันแบบ prod-like ได้ด้วยdocker compose -f docker-compose.yml up --buildnginx.confที่เสิร์ฟ build, proxy/apiและมี SPA fallbackdocker-compose.override.ymlที่เปิด hot reload- ภาพหน้าจอ
docker compose psที่แสดงสองบริการกำลังรัน (ทั้งโหมด prod และ dev) - วิดีโอหรือภาพก่อน/หลัง: แก้โค้ดแล้วเบราว์เซอร์อัปเดตทันที (HMR)
- คำอธิบายสั้น ๆ ว่าไฟล์ base กับ override ต่างกันอย่างไร และกฎการรวมที่ใช้มีอะไรบ้าง (ใช้ตารางในขั้นที่ 8 เป็นแนวทาง)
ทดสอบความเข้าใจ
อยากไปต่อ? ลองทำโจทย์ท้าทาย
- เพิ่ม healthcheck ให้
apiแล้วเปลี่ยนdepends_onของwebเป็นcondition: service_healthy - ลองใช้
docker compose watchแทน bind mount แล้วเปรียบเทียบว่าต่างกันอย่างไร - เพิ่มหน้า "เพิ่มสินค้า" ที่
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 ที่พอร์ต 8080 | proxy_pass ไม่มี / ท้าย หรือชื่อ service ไม่ตรง | ตรวจขั้นที่ 3 |
/api/... ค้างนานประมาณ 60 วินาทีแล้วได้ 504 Gateway Time-out | container api หยุดทำงาน (ทดสอบด้วย docker compose stop api) | docker compose ps -a และ docker compose logs api ดูว่า api หยุดเพราะอะไร |
localhost:8080 ขึ้น ERR_EMPTY_RESPONSE | สลับจาก dev มา prod โดยไม่ใส่ --build | docker 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
- How merging works (ไฟล์ override อัตโนมัติ,
-f) — https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/ - Compose file reference: Merge (
!override,!reset, กฎของ sequence และ volumes) — https://docs.docker.com/reference/compose-file/merge/ - Compose file reference: Build (
target,build+image) — https://docs.docker.com/reference/compose-file/build/ - Compose file reference: Services (
depends_on) — https://docs.docker.com/reference/compose-file/services/ - Networking in Compose — https://docs.docker.com/compose/how-tos/networking/
docker compose up(--build,-d,-V) — https://docs.docker.com/reference/cli/docker/compose/up/docker compose down(-v) — https://docs.docker.com/reference/cli/docker/compose/down/- Use Compose Watch — https://docs.docker.com/compose/how-tos/file-watch/
Docker
- Multi-stage builds — https://docs.docker.com/build/building/multi-stage/
- Volumes (anonymous volume, การคัดลอกไฟล์เข้า volume ว่าง) — https://docs.docker.com/engine/storage/volumes/
- Docker Desktop: Explore the Containers view — https://docs.docker.com/desktop/use-desktop/container/
Nginx
proxy_pass— https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_passtry_files— https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files
Vite / React / Redux
- Vite Server Options (
server.proxy,server.watch) — https://vite.dev/config/server-options - Vite Troubleshooting: HMR — https://vite.dev/guide/troubleshooting
- Redux Toolkit
createAsyncThunk— https://redux-toolkit.js.org/api/createAsyncThunk
json-server และ Node.js
- json-server README (v1 beta) — https://github.com/typicode/json-server
- Node.js Release schedule — https://github.com/nodejs/Release/blob/main/schedule.json