Full Compose Stack
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”taskflow/infra/docker-compose.yml ฉบับสมบูรณ์ — service db และ redis จาก compose-skeleton ตอนนี้มี backend (build จาก backend-image) และ frontend (build จาก frontend-image) มาร่วมด้วย คำสั่งเดียว docker compose up --build เปิด container ทั้งสี่ตัวตามลำดับการพึ่งพากันจริงๆ และผลิตภัณฑ์ทั้งหมด — register, login, สร้าง board, ลากการ์ด, ดูการ์ดย้ายแบบ live ในอีกแท็บ — รันอยู่ใน Docker ทั้งหมด ไม่ต้องมี cargo run หรือ npm run dev แบบ native ที่ไหนเลย
taskflow/├── .env├── backend/├── frontend/├── infra/│ ├── backend.Dockerfile│ ├── frontend.Dockerfile│ └── docker-compose.yml # complete now: db, redis, backend, frontend└── migrations/ตอนนี้สี่ service เริ่มทำงานจาก docker compose up เดียว แต่จะปล่อยให้ทุกตัวเริ่มพร้อมกันแล้วหวังว่าจะรอดไม่ได้ การกระทำแรกสุดของ backend — ก่อนจะเปิด listening socket ด้วยซ้ำ — คือ sqlx::migrate!(...).run(&db).await? จาก backend-image ซึ่งต้องการ Postgres ที่ไม่ใช่แค่ รันอยู่ แต่ต้อง รับ connection ได้จริง เริ่ม backend ทันทีที่ container process ของ db เริ่มทำงาน และการเรียก migration จะไปชนกับ socket ที่ยังไม่มีใครฟังอยู่ — เป็นความล้มเหลวจริงและพบบ่อยกับ depends_on ธรรมดา (ซึ่งโดย default จะรอแค่ container เริ่มทำงาน ไม่ใช่รอให้สิ่งที่อยู่ข้างใน พร้อม) นี่คือเหตุผลที่ db และ redis มี healthcheck มาตั้งแต่ compose-skeleton แล้ว ทั้งที่ตอนนั้นยังไม่มีอะไรพึ่งพา healthcheck นั้นเลย — บทเรียนนี้คือจุดที่งานพื้นฐานนั้นได้ผลตอบแทนในที่สุด ผ่าน depends_on: { condition: service_healthy }
frontend ที่พึ่งพา backend เป็นการพึ่งพาแบบที่ต่างออกไปและอ่อนกว่า container ของ frontend ไม่เคยเรียก backend โดยตรงเลย — frontend-image วางไว้แล้วว่า PUBLIC_API_URL ชี้ไปที่ http://localhost:8080 ที่ browser เห็น และเป็น browser ไม่ใช่ Node server ใน container frontend ที่เรียก /auth/register, /boards และ /ws/boards/:id ทุกครั้ง frontend ที่มี depends_on: backend มีไว้เพื่อให้ log output และลำดับการเริ่มทำงานของ docker compose up อ่านแล้วสมเหตุสมผลสำหรับคนที่เฝ้าดูตอนเริ่มทำงาน — ไม่ใช่เพราะ container ของ frontend มีความจำเป็นทางเทคนิคใดๆ ที่ต้องให้ backend ตอบ request ได้
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”depends_on ที่ผูกกับ condition: service_healthy (แบบที่เราใช้กับ backend) เทียบกับ depends_on ธรรมดา (รอแค่ container เริ่มทำงาน) บวก logic retry ในแอปเอง
- ข้อดี: การรับประกันลำดับอยู่ในที่เดียวแบบ declarative — compose file — แทนที่จะไป implement ใหม่เป็น connection-retry-with-backoff logic ใน
main.rsและยังถูกต้องอย่างเคร่งครัดสำหรับความล้มเหลวที่สำคัญจริงๆ ตรงนี้: “container process ของ Postgres มีอยู่” กับ “Postgres รับ connection ได้” เป็นช่วงเวลาที่ต่างกันจริงๆ บางครั้งต่างกันหลายวินาที และ healthcheck ที่ใช้pg_isreadyเป็นตัวบอกความต่างนั้นได้แม่นยำ - ข้อเสีย:
depends_onรับประกันแค่ว่าdbและredishealthy ตอนที่backendเริ่มทำงาน เท่านั้น — ถ้า Postgres เข้าถึงไม่ได้หลังจากbackendรันไปสิบนาทีแล้ว (network สะดุด,dbcontainer restart)depends_onไม่ช่วยอะไรเลย ต้องจัดการด้วย connection-pool retry logic ในแอปเอง ซึ่ง pool ของ SQLx ก็ทำอยู่แล้วในระดับ per-query การจัดลำดับ dependency ของ Compose กับความทนทานของแอปต่อ dependency ที่ หายไปทีหลัง เป็นปัญหาคนละเรื่อง และบทเรียนนี้แก้แค่เรื่องแรกเท่านั้น
.env ไฟล์เดียวที่ใช้ร่วมกันผ่าน env_file โดยที่ block environment: ของ backend override แค่ DATABASE_URL/REDIS_URL (แบบที่เราใช้) เทียบกับ env file แยกสองไฟล์ — ไฟล์หนึ่งสำหรับ cargo run แบบ native อีกไฟล์สำหรับ Compose
- ข้อดี: ไฟล์เดียวยังคงเป็นแหล่งความจริงเดียวสำหรับ
JWT_SECRET,APP_PORTและFRONTEND_ORIGIN— ค่าที่ไม่เปลี่ยนไม่ว่าจะรัน backend แบบ native หรือใน container กฎ precedence ของ Compose เอง (environment:ชนะenv_file:สำหรับ key ที่มีอยู่ทั้งสองที่) จัดการส่วนที่เหลือ:DATABASE_URLและREDIS_URLจาก.envยังคงบอกlocalhostถูกต้องสำหรับการใช้sqlx-cliแบบ native ของ migrations และถูก override เฉพาะสำหรับ servicebackendที่อยู่ใน container ไปเป็นdb/redis— hostname ที่ DNS ภายในของ Docker resolve ได้จริง - ข้อเสีย: ไม่ชัดเจนในทันทีว่า
DATABASE_URLที่backendเห็นจริงๆ ต่างจากที่เขียนไว้ใน.env— ผู้เรียนที่ grep.envหา connection string แล้วไม่สังเกต override ในdocker-compose.ymlจะสับสนในครั้งแรกdocker compose config(ใช้ในส่วน Verify ของบทเรียนนี้) คือวิธีตรงที่สุดในการดูค่าที่ resolve แล้วจริงๆ
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”1. infra/docker-compose.yml — ฉบับสมบูรณ์
หัวข้อที่มีชื่อว่า “1. infra/docker-compose.yml — ฉบับสมบูรณ์”services: db: image: postgres:16 environment: POSTGRES_USER: taskflow POSTGRES_PASSWORD: taskflow POSTGRES_DB: taskflow ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U taskflow"] interval: 5s timeout: 3s retries: 5
redis: image: redis:7 ports: - "6379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5
backend: build: context: .. dockerfile: infra/backend.Dockerfile env_file: - ../.env environment: DATABASE_URL: postgres://taskflow:taskflow@db:5432/taskflow REDIS_URL: redis://redis:6379 depends_on: db: condition: service_healthy redis: condition: service_healthy ports: - "8080:8080" healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"] interval: 5s timeout: 3s retries: 5 start_period: 10s
frontend: build: context: .. dockerfile: infra/frontend.Dockerfile args: PUBLIC_API_URL: http://localhost:8080 depends_on: - backend ports: - "4321:4321"
volumes: pgdata: {}สิ่งที่เปลี่ยนไปตั้งแต่ compose-skeleton: db และ redis ไม่ถูกแตะต้องเลย เหมือนเดิมทุก byte backend และ frontend เป็นของใหม่ — ทั้งคู่ build: จาก image ที่สองบทเรียนที่แล้วเขียนไว้ ทั้งคู่เข้าถึงได้จากเครื่องคุณที่ port เดียวกับที่ app-skeleton และ board-page ใช้มาตลอด คือ 8080 และ 4321
env_file: [../.env]บนbackendโหลดทุกตัวแปรจาก.envที่ root ที่ repo-layout สร้างไว้ —JWT_SECRET,APP_PORT,FRONTEND_ORIGINและในตอนแรกDATABASE_URL/REDIS_URLด้วยenvironment:บนbackendที่อยู่หลังenv_fileoverride สอง key นั้นเท่านั้น — Compose merge ทั้งสองเข้าด้วยกัน โดยenvironment:ชนะสำหรับ key ที่มีอยู่ทั้งสองที่ นี่คือข้อเท็จจริงเรื่องdbไม่ใช่localhostจากตาราง environment แรกสุดของคอร์สนี้ ที่ตอนนี้เป็นรูปธรรม: ภายใน network ของ Compose นั้น Postgres และ Redis เข้าถึงได้ด้วย ชื่อ service ไม่ใช่localhosthealthcheckของbackendเอง curl route/healthของตัวเองจาก app-skeleton — endpoint เดียวกับที่คอร์สนี้ใช้มาตั้งแต่โมดูล 3 เพื่อบอกว่า “process นี้พร้อมและ router ทำงานอยู่” ส่วนstart_period: 10sให้ grace period กับ container (ไม่ได้ compile อะไรตอนนี้ แต่การเชื่อมต่อ Postgres และรัน migration ก็ยังใช้เวลาสักครู่) ก่อนจะเริ่มนับการเช็กครั้งแรกที่ช้าแต่ไม่ได้พังจริงรวมเข้ากับretriesbuild.args: { PUBLIC_API_URL: http://localhost:8080 }ของfrontendคือ build-timeARGที่ frontend-image เชื่อมไว้ —localhostไม่ใช่backendเพราะ frontend-image วางไว้แล้วว่า browser คือสิ่งที่อ่านค่านี้ และ browser ของผู้เรียนไม่มีทาง resolve hostnamebackendภายในของ Docker ได้- ไม่มี key
version:ระดับบนสุด — หมายเหตุเดียวกับ compose-skeleton: Compose รุ่นใหม่ไม่ต้องการแล้ว
2. เปิดทั้ง stack ขึ้นมา
หัวข้อที่มีชื่อว่า “2. เปิดทั้ง stack ขึ้นมา”จาก taskflow/infra/:
cd taskflow/infradocker compose up --build--build บังคับให้ backend และ frontend (re)build image จาก source ปัจจุบันก่อนเริ่มทำงาน — จำเป็นในครั้งแรก และทุกครั้งที่คุณแก้โค้ด backend หรือ frontend ตั้งแต่ build ครั้งล่าสุด Compose เปิด service โดยเคารพ depends_on: db และ redis เริ่มก่อนและ Compose รอให้ healthcheck ทั้งสองผ่าน จากนั้น backend ถึงเริ่ม (รอ healthcheck ของตัวเอง) และ frontend เริ่มเมื่อ container ของ backend เริ่มทำงานแล้ว (ไม่จำเป็นต้อง healthy ตามที่อธิบายในส่วน Why)
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”ยืนยันว่า container ทั้งสี่ healthy
หัวข้อที่มีชื่อว่า “ยืนยันว่า container ทั้งสี่ healthy”docker compose psที่คาดหวัง — db, redis และ backend ทั้งหมดแสดง healthy; frontend ไม่มี healthcheck กำหนดไว้ตรงนี้ จึงแสดง Up:
NAME IMAGE STATUS PORTSinfra-backend-1 infra-backend Up 20 seconds (healthy) 0.0.0.0:8080->8080/tcpinfra-db-1 postgres:16 Up 30 seconds (healthy) 0.0.0.0:5432->5432/tcpinfra-frontend-1 infra-frontend Up 15 seconds 0.0.0.0:4321->4321/tcpinfra-redis-1 redis:7 Up 30 seconds (healthy) 0.0.0.0:6379->6379/tcpถ้า backend ไม่ยอมขึ้น healthy ให้เช็ก log ก่อนอย่างอื่น:
docker compose logs backendไล่ตรวจรับทั้งระบบแบบ end-to-end
หัวข้อที่มีชื่อว่า “ไล่ตรวจรับทั้งระบบแบบ end-to-end”นี่คือผลิตภัณฑ์ทั้งหมด รันจาก docker compose up เดียว ทดสอบแบบเดียวกับที่ผู้ใช้จริงจะทำ — register, login, สร้าง board และดูการลาก-วางย้ายแบบ live sync ข้ามสองแท็บ
-
Register เปิด
http://localhost:4321/register(auth-pages) กรอกอีเมล รหัสผ่าน และชื่อที่แสดง แล้ว submit คุณควรมาอยู่ที่/โดย nav bar แสดง “Boards / Log out” —localStorage.getItem('token')ใน devtools มี JWT ที่ backend ออกให้ -
สร้าง board ยังอยู่ที่
/(boards-list) ใช้ create-board form สร้าง board ชื่อSprint 1แล้ว board จะโผล่ในลิสต์ทันที คลิกเข้าไป แล้ว URL bar จะเป็นhttp://localhost:4321/boards/<uuid>— copy<uuid>นั้นเก็บไว้ เดี๋ยวต้องใช้ ตัวหน้าบอร์ดจะโหลดขึ้นมาโดยยังไม่มี column เลย เพราะยังไม่ได้สร้างไว้ -
Seed column และ card drag-drop สร้าง drag-and-drop interaction แต่ตั้งใจไม่สร้าง UI สำหรับสร้าง column/card เลย — นี่เป็นขอบเขตที่แท้จริงและถูกบันทึกไว้ของ frontend ในคอร์สนี้ ไม่ใช่การพลาด seed board ที่คุณเพิ่งสร้างผ่าน API โดยตรง ใช้ account เดิม (login ผ่าน
curlเพื่อเอา token มา แทนที่จะ register อันใหม่):Terminal window TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \-H "Content-Type: application/json" \-d '{"email":"<the email you registered with>","password":"<the password you used>"}' \| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')BOARD_ID=<the uuid you copied from the URL bar in step 2>TODO=$(curl -s -X POST http://localhost:8080/boards/$BOARD_ID/columns \-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \-d '{"title":"To Do"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')DOING=$(curl -s -X POST http://localhost:8080/boards/$BOARD_ID/columns \-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \-d '{"title":"Doing"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')curl -s -X POST http://localhost:8080/columns/$TODO/cards \-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"title":"Card A"}'curl -s -X POST http://localhost:8080/columns/$TODO/cards \-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"title":"Card B"}' -
Reload board กลับไปที่ browser tab จากขั้นตอนที่ 2 reload
http://localhost:4321/boards/$BOARD_IDตอนนี้ทั้งสอง column แสดงพร้อมการ์ดข้างในแล้ว -
เปิดแท็บที่สอง เปิด URL
http://localhost:4321/boards/$BOARD_IDเดียวกันใน browser tab ที่ สอง (localStorageแชร์ข้ามแท็บใน browser เดียวกัน แท็บใหม่จึง login อยู่แล้ว ไม่ต้อง login ซ้ำ) -
ลาก แล้วดูการ sync ในแท็บ 1 ลากการ์ด “Card A” จาก “To Do” ไปที่ “Doing” การ์ดจะย้ายทันทีในแท็บ 1 (optimistic update ของ drag-drop) จากนั้นภายในไม่กี่วินาที การ์ดก็ย้ายใน แท็บ 2 ด้วยโดยไม่ต้อง reload — นั่นคือ event
card.movedของ ws-endpoint ที่ publish ผ่าน redis-backplane เดินทางมาตาม WebSocket ที่ live-sync เชื่อมเข้ากับBoard.tsxแล้วreconcileก็ apply event นั้นกับ state ของแท็บ 2 ตาม id
ขั้นตอนสุดท้ายนั้นคือผลตอบแทนของทั้งคอร์ส: browser tab อิสระสองแท็บ, WebSocket connection อิสระสองอัน, Hub subscriber อิสระสองตัวบน backend ทั้งหมดเห็นตรงกันเรื่อง state ของ board เดียวกันแบบ real time — รันจาก docker compose up --build เพียงคำสั่งเดียว
แก้ปัญหาที่พบบ่อย
หัวข้อที่มีชื่อว่า “แก้ปัญหาที่พบบ่อย”Port ชนกับของเดิม docker compose up fail ทันทีด้วยอะไรประมาณ Bind for 0.0.0.0:5432 failed: port is already allocated มีอย่างอื่นบนเครื่องคุณ — Postgres หรือ Redis แบบ native จาก docker compose up -d db redis ก่อนหน้านี้ของ compose-skeleton ที่ยังรันอยู่ หรือ project อื่นที่ไม่เกี่ยวกัน — ครอบครอง port นั้นอยู่แล้ว หาด้วย lsof -i :5432 (เปลี่ยนเป็น port ที่ fail) แล้วหยุด process นั้น หรือเปลี่ยนฝั่ง host ของ ports: mapping ของ service นั้น (เช่น "5433:5432") ถ้าอยากให้ทั้งสองรันพร้อมกัน
CORS error ใน browser console fetch จาก /register หรือ /login ที่ fail พร้อม error ใน browser console ที่พูดถึง Access-Control-Allow-Origin หมายความว่า FRONTEND_ORIGIN ใน .env ไม่ตรงกับ origin ที่ browser รันอยู่จริงๆ เป๊ะๆ — scheme, host และ port ต้องตรงกันหมดกับ CorsLayer แบบ exact-origin ของ app-skeleton ค่าที่ถูกคือ FRONTEND_ORIGIN=http://localhost:4321 ยืนยันว่า backend เห็นอะไรจริงๆ ตอน runtime ด้วย docker compose exec backend env | grep FRONTEND_ORIGIN แล้ว docker compose restart backend หลังแก้ .env — ไม่ต้อง rebuild เพราะ FRONTEND_ORIGIN อ่านจาก environment ตอน process เริ่ม ไม่ได้ฝังลง image แบบ PUBLIC_API_URL
db เทียบกับ localhost — กับดักคลาสสิก ถ้า backend restart วนซ้ำและ docker compose logs backend แสดงอะไรประมาณ error connecting to server: Connection refused หรือ failed to lookup address information: Name or service not known สาเหตุที่เป็นไปได้มากที่สุดคือ DATABASE_URL (หรือ REDIS_URL) ชี้ไปที่ localhost ภายใน container ของ backend ซึ่งตรงนั้น localhost หมายถึง container ของ backend เอง ไม่ใช่ container ของ db จึงไม่มีอะไรฟังอยู่ นี่คือสิ่งที่ override ใน environment: ของ docker-compose.yml มีไว้เพื่อป้องกันโดยเฉพาะ ถ้าคุณแก้ compose file แล้ว block override นั้นหายไป backend จะ fallback ไปใช้ค่า native-dev ของ .env (@localhost:5432) และพังทันทีเมื่อรันอยู่ใน network ของ Docker แทนที่จะเป็นบนเครื่องคุณ วิธีแก้เหมือนเดิมเสมอ: อะไรก็ตามที่รัน อยู่ภายใน network ของ Compose เข้าถึง Postgres และ Redis ด้วย ชื่อ service (db, redis); ส่วน localhost ใช้ได้เฉพาะกับ process ที่รันตรงๆ บนเครื่อง host ของคุณนอก Docker เท่านั้น เช่น sqlx-cli หรือ cargo run แบบ native
taskflow/infra/docker-compose.yml สมบูรณ์แล้ว: db และ redis จากโมดูล 1 มี backend (รอทั้งสองผ่าน condition: service_healthy ก่อนจะรัน migration ที่ฝังไว้) และ frontend (รอแค่ backend เริ่มทำงานแล้ว เพราะ browser — ไม่ใช่ container ของ frontend — คือสิ่งที่เรียก API จริงๆ) มาร่วมด้วย docker compose up --build เปิดทั้งสี่จากคำสั่งเดียว docker compose ps ยืนยัน healthcheck จริงสามตัวผ่าน และ end-to-end walk พิสูจน์จุดประสงค์ทั้งหมดของคอร์สนี้: register และ login ผ่าน form จริง สร้าง board ผ่าน form จริง seed column และ card ผ่าน API เดียวกับที่ move-reorder และ drag-drop ใช้ในส่วน Verify ของตัวเองอยู่แล้ว จากนั้นดูการลากในแท็บ browser หนึ่ง sync แบบ live ไปยังอีกแท็บโดยไม่ต้อง reload — authentication, REST API, Postgres, Redis และ realtime layer แบบ WebSocket ทั้งหมดรันจาก container ที่โมดูลนี้ build ขึ้นมา นั่นคือ TaskFlow ตั้งแต่ต้นจนจบ