The full stack
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”infra/docker-compose.yml ต่อยอดจากไฟล์ที่มีแค่ mongo ของ Compose skeleton ไปสู่ทั้ง stack: mongo (ไม่เปลี่ยน), api (build จาก Dockerfile ของ The API image รอ healthcheck ของ Mongo ก่อนเริ่ม) และ web (build จาก Dockerfile ของ The web image ต่อสายเข้ากับ API URL ทั้งสองแบบที่บทเรียนนั้นแนะนำ) คำสั่งเดียว docker compose up --build จะยกทุกอย่างที่ DevBlog ต้องการขึ้นมา ที่เหลือของบทเรียนนี้คือการเดินทางเดียวรวดจากฐานข้อมูลเปล่าไปจนถึงโพสต์ที่ publish แล้วพร้อมคอมเมนต์ที่ approve แล้ว ผ่าน stack ที่รันจริงทั้งหมด
ทุกชิ้นส่วนที่บทเรียนนี้เอามาประกอบกันมีอยู่ครบแล้วในตัวเอง mongo รันเดี่ยวมาตั้งแต่ Compose skeleton api.Dockerfile กับ web.Dockerfile ทั้งคู่ build และรันแบบ standalone ได้ ยืนยันแล้วด้วย docker run เปล่า ๆ ในสองบทเรียนก่อนหน้า สิ่งที่ยังไม่มีบทเรียนไหนแสดงให้เห็น คือพฤติกรรมที่เกิดขึ้นก็ต่อเมื่อทั้งสามตัวรันร่วมกัน บน network เดียวกัน ตามลำดับที่ถูกต้องเท่านั้น: api เข้าถึง mongo ด้วยชื่อ service จริง ๆ, web เข้าถึง api ด้วยชื่อ service จริง ๆ จากโค้ดที่ render บนเซิร์ฟเวอร์ ส่วน browser เข้าถึงทั้ง web และ api ผ่าน localhost บน port ที่ publish ออกมา นี่คือเส้นแบ่งเป๊ะ ๆ ที่การแก้ gqlFetch ใน The web image มีไว้รองรับ
ลำดับการเริ่มทำงานสำคัญที่นี่ในแบบที่ docker run คอนเทนเนอร์เดียวไม่เคยแสดงให้เห็น MongooseModule.forRootAsync จาก Config & exceptions เรียก configService.getOrThrow('MONGODB_URI') แล้วเชื่อมต่อทันทีตอนบูต — ถ้าคอนเทนเนอร์ api เริ่มก่อนที่ root user ของ mongo จะมีอยู่จริง (bootstrap ที่ Compose skeleton อธิบายไว้ว่ารันแค่ครั้งเดียวตอน Mongo เริ่มครั้งแรกด้วย data directory ที่ว่างเปล่า) ความพยายามเชื่อมต่อครั้งแรกสุดจะล้มเหลวด้วย authentication error ไม่ใช่ retry แบบ “ยังเริ่มอยู่ ลองใหม่” depends_on: mongo: condition: service_healthy คือสิ่งที่ปิดช่องว่างนั้น: Compose จะไม่เริ่มคอนเทนเนอร์ api เลยจนกว่า healthcheck ของ mongo เอง — คือ mongosh --eval ping ตัวเดียวกับจาก Compose skeleton — จะรายงานว่า healthy ไม่ใช่แค่ “container process เริ่มแล้ว” depends_on: [mongo] เปล่า ๆ ที่ไม่มี condition จะรอแค่อย่างหลังเท่านั้น ซึ่งใน docker compose up แบบเย็น (cold start) มักจะเกิดขึ้นก่อนที่ Mongo จะสร้าง root user เสร็จ
ทำไม depends_on: api ของ web ถึงไม่ขอ condition: service_healthy แบบเดียวกัน เซิร์ฟเวอร์ Next.js ของ web ไม่ได้สตาร์ตไม่ขึ้นถ้า api ยังเข้าถึงไม่ได้ เพราะไม่มี Server Component ไหนดึงข้อมูลตอนบูตเลย จะดึงก็ต่อเมื่อมี request เข้ามาจริง ซึ่งตอนนั้น api มักตามทันไปแล้ว การบังคับให้ api healthy เต็มที่ก่อน web จะเริ่มได้ ถือเป็นทางเลือกที่เข้มงวดและระวังกว่า แต่ Compose file นี้เลือกยอมรับช่องว่างเล็ก ๆ ที่มีอยู่จริงแทน คือ “request แรกสุดทันทีหลัง cold start อาจไปโดนคอนเทนเนอร์ web ที่ api ยังบูตไม่เสร็จ” ดีกว่าทำให้ docker compose up ช้าลงทุกครั้งเพื่อรอ healthcheck ที่มักหมดความสำคัญไปแล้วตอนคนจริง ๆ เปิด browser
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”depends_on: condition: service_healthy (ที่ api→mongo ใช้) เทียบกับ depends_on แบบ list เปล่า ๆ ไม่มี condition depends_on: [mongo] ที่ไม่มี condition รับประกันแค่ว่า Docker เริ่มคอนเทนเนอร์ตามลำดับที่ถูกต้อง ไม่ได้บอกอะไรเลยว่าสิ่งที่อยู่ข้างในคอนเทนเนอร์ mongo พร้อมรับ connection แล้วหรือยัง ซึ่งสำหรับฐานข้อมูลที่มีขั้นตอน bootstrap ตอนรันครั้งแรก เป็นคนละคำถามกับ “process เริ่มแล้วหรือยัง” อย่างมีนัยสำคัญ
ส่วน condition: service_healthy มีต้นทุนแค่ healthcheck definition ตัวเดียว (ซึ่งเขียนไว้แล้ว ใช้ซ้ำจาก Compose skeleton) แล้วให้ Compose poll healthcheck นั้นก่อนปล่อยคอนเทนเนอร์ที่ dependent ให้เริ่ม เป็น default ที่ถูกต้องสำหรับ service ที่ตัว dependent จะ fail ตรง ๆ แทนที่จะ retry อย่างสุภาพ ต้นทุนคือ cold start ของ docker compose up ช้าลงเพราะต้องรอ start_period ของ Mongo แลกกับการที่ api ไม่มีวันพยายามเชื่อมต่อก่อนที่จะเชื่อมสำเร็จได้
docker-compose.yml ไฟล์เดียวสำหรับทั้ง stack (แบบที่เราใช้) เทียบกับ Compose file แยกต่อ service แล้วรวมกันด้วย -f ไฟล์เดียวคือสิ่งเดียวที่ต้องอ่านตั้งแต่บนลงล่างเพื่อเข้าใจทั้งระบบ และ depends_on อ้างถึง service อื่นได้ตรง ๆ ด้วยชื่อโดยไม่ต้องต่อสายข้ามไฟล์ ถ้าแยก mongo, api และ web เป็น Compose file ของตัวเอง (docker compose -f mongo.yml -f api.yml -f web.yml up) ไฟล์ของแต่ละ service จะอยู่ใกล้โค้ดที่ตัวเอง build มากขึ้น และ version หรือใช้ซ้ำได้อิสระ ซึ่งมีประโยชน์จริงในองค์กรใหญ่ที่คนละทีมเป็นเจ้าของคนละ service แต่สำหรับสาม service ที่รันด้วยกันเสมอในที่เก็บโค้ดเดียว ไฟล์เดี่ยวอ่านง่ายกว่า และไม่มีอะไรตรงนี้ได้ประโยชน์จากการ compose แยกอิสระเลย
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”แทนที่ infra/docker-compose.yml ด้วยทั้ง stack:
services: mongo: image: mongo:7 environment: MONGO_INITDB_ROOT_USERNAME: devblog MONGO_INITDB_ROOT_PASSWORD: devblog ports: - "27017:27017" volumes: - mongodata:/data/db healthcheck: test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 10s timeout: 5s retries: 5 start_period: 10s
api: build: context: .. dockerfile: infra/api.Dockerfile env_file: - ../.env environment: MONGODB_URI: mongodb://devblog:devblog@mongo:27017/devblog?authSource=admin depends_on: mongo: condition: service_healthy ports: - "4000:4000" healthcheck: test: [ "CMD", "node", "-e", "require('http').get('http://localhost:4000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))", ] interval: 10s timeout: 5s retries: 5 start_period: 10s
web: build: context: .. dockerfile: infra/web.Dockerfile args: NEXT_PUBLIC_API_URL: http://localhost:4000/graphql environment: API_URL_INTERNAL: http://api:4000/graphql depends_on: - api ports: - "3000:3000"
volumes: mongodata: {}build.context: ..สำหรับทั้งapiและwebไม่ใช่.—infra/docker-compose.ymlอยู่ข้างในinfra/แต่api.Dockerfileกับweb.Dockerfileทั้งคู่COPY apps/api/.../COPY apps/web/...จาก The API image และ The web image ซึ่ง resolve ได้ก็ต่อเมื่อเทียบกับ root ของ monorepo เท่านั้นdockerfile: infra/api.Dockerfileจึงเป็น path ที่สัมพันธ์กับ context เดียวกันนั้นenv_file: [../.env]บวกกับenvironment:override บนMONGODB_URI—apiโหลดตัวแปรอื่นทุกตัว (JWT_SECRET,WEB_ORIGIN,API_PORT) ตรงจาก.envที่ root ตัวเดียวกับที่ทุกโมดูลอ่านอยู่แล้ว แต่MONGODB_URIโดยเฉพาะถูก override ให้ชี้ไปที่mongo(ชื่อ service ของ Compose) แทนที่localhost— ค่าใน.envจาก Repo layout ถูกต้องสำหรับnpm run start:devบน host และผิดสำหรับคอนเทนเนอร์บน Compose network ซึ่งเข้าถึงlocalhost:27017ของ host ด้วยชื่อนั้นไม่ได้เลยhealthcheckบนapiเรียกnode -eไปที่http://localhost:4000/healthไม่ใช่curlnode:20-slimไม่มีทั้งcurlและwgetติดตั้งมา การเพิ่มเข้ามาแค่เพื่อให้ healthcheck ผ่านเท่ากับแบก package เพิ่มใน runtime image สำหรับงานที่ modulehttpในตัวของ Node ทำได้อยู่แล้ว/healthเองคือ route ของAppControllerจาก The app module ไม่เปลี่ยนแปลงตั้งแต่บทเรียนนั้นargs.NEXT_PUBLIC_API_URLของwebคือhttp://localhost:4000/graphqlตรงกับ port ที่ Compose publish (4000:4000) ให้ host — นี่คือ address ที่ browser บนเครื่องนักพัฒนาจะใช้จริง ๆ ค่าเดียวเป๊ะกับที่ The web image ฝังเข้าไปใน bundle ด้วยมือผ่าน--build-argenvironment.API_URL_INTERNALของwebคือhttp://api:4000/graphql—apiไม่ใช่localhostเพราะค่านี้ถูกอ่านโดยโค้ดฝั่งเซิร์ฟเวอร์ที่รันข้างในคอนเทนเนอร์webเอง ซึ่งเข้าถึงคอนเทนเนอร์apiด้วยชื่อ service ของ Compose ผ่าน network ที่ Compose สร้างให้อัตโนมัติสำหรับทุก service ในไฟล์นี้
ยกทั้ง stack ขึ้นมา rebuild ทั้งสอง image:
cd devblog/infradocker compose up --buildตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”docker compose ps# NAME IMAGE STATUS# infra-mongo-1 mongo:7 Up (healthy)# infra-api-1 infra-api Up (healthy)# infra-web-1 infra-web Upapi ที่ไปถึง (healthy) ยืนยันว่าสามอย่างเกิดขึ้นตามลำดับ: Mongo ผ่าน healthcheck ของตัวเอง, api ได้รับอนุญาตให้เริ่ม และการเชื่อมต่อ Mongoose ของ api ไปที่ mongo:27017 (ไม่ใช่ localhost) สำเร็จ — handshake เดียวกันกับที่ Compose skeleton ยืนยันสำหรับ Mongo เดี่ยว ๆ ตอนนี้พิสูจน์แล้วแบบ end-to-end จากข้างในคอนเทนเนอร์อีกตัวหนึ่ง
เดินระบบ acceptance ตั้งแต่ต้นจนจบ
หัวข้อที่มีชื่อว่า “เดินระบบ acceptance ตั้งแต่ต้นจนจบ”นี่คือการเดินทางเดียวกับที่ Playwright sketch ของ Frontend tests เอ่ยชื่อไว้แต่รันไม่ได้ — ขับเคลื่อนที่นี่ต่อ stack ที่ containerize จริง
1. Seed admin หนึ่งคน เปิด Apollo Sandbox ที่ http://localhost:4000/graphql แล้วลงทะเบียนผู้ใช้ ด้วย mutation register ตัวเดียวกันจาก Auth resolver & GraphQL setup:
mutation Register { register( input: { email: "admin@example.com" password: "correct-horse" displayName: "Site Admin" } ) { token }}ผู้ใช้ทุกคนที่ schema นี้สร้างได้จะได้ role: 'author' เป็น default — ค่า default ของ schema จาก Data modeling และไม่มี mutation ไหนเลยในคอร์สนี้ที่ promote ผู้ใช้ไปเป็น 'admin' นั่นคือช่องว่างจริงที่ต้องเอ่ยชื่อไว้ตรง ๆ: deployment จริงต้องมีวิธีที่ชัดเจนและตรวจสอบได้ในการสร้าง administrator คนแรก (seed script แบบรันครั้งเดียว หรือ flag ของ CLI ตอนบูตครั้งแรก) ซึ่ง DevBlog ไม่ได้ทำไว้ให้ สำหรับการเดินระบบรอบนี้ ให้ promote ผู้ใช้ด้วยมือ ตรงเข้าไปที่คอนเทนเนอร์ Mongo ที่รันอยู่:
docker compose exec mongo mongosh \ "mongodb://devblog:devblog@localhost:27017/devblog?authSource=admin" \ --eval "db.users.updateOne({ email: 'admin@example.com' }, { \$set: { role: 'admin' } })"{ acknowledged: true, matchedCount: 1, modifiedCount: 1, ... }2. Log in เข้า /admin เปิด http://localhost:3000/admin/login แล้ว sign in ด้วย admin@example.com / correct-horse — AdminLoginPage ตัวเดียวกันจาก Admin auth คุณควรลงเอยที่ dashboard พร้อม token ที่ตอนนี้มี role: "admin" แล้ว
3. สร้างและ publish โพสต์ เปิด /admin/posts/new เขียน title กับ body เป็น Markdown แล้ว save (นี่จะเรียก createPost แล้ว redirect ไปหน้า edit จาก Post editor) แล้วไปที่ /admin/posts แล้วคลิก Publish
4. ดูโพสต์บนหน้าแรกของบล็อกสาธารณะ เปิด http://localhost:3000 ในแท็บใหม่ The home list ดึงข้อมูลด้วย revalidate: 60 และไม่มี revalidateTag ต่อสายไว้กับ publishPost — ช่องว่างจริงที่เอ่ยชื่อไว้แล้วจากบทเรียนนั้น ไม่ใช่เรื่องใหม่ของ Docker ถ้าโพสต์ที่คุณเพิ่ง publish ยังไม่ขึ้น ให้รอไม่เกิน 60 วินาทีแล้ว reload ใหม่ นี่ไม่ใช่ปัญหา networking ของ Docker แต่เป็น cache window ของ ISR ฝั่ง Next ที่รอหมดอายุตามกำหนดเวลา
5. เพิ่มคอมเมนต์ เปิดโพสต์ที่ publish แล้วที่ /posts/<slug ของโพสต์> แล้ว submit CommentForm ในฐานะผู้เข้าชมที่ไม่ระบุตัวตน คุณควรเห็น “Thanks — your comment is awaiting moderation.” ข้อความเดียวกับที่ Post page สร้างไว้ ส่วนตัวคอมเมนต์เองไม่ควรโผล่ในรายการบนหน้าเดียวกันนี้เลย
6. Approve คอมเมนต์ที่ /admin/comments กลับไปที่ session admin เปิด /admin/comments คอมเมนต์ที่ pending ควรอยู่ในรายการ จาก query แบบ fan-out ของ Moderation UI แล้วคลิก Approve
7. ยืนยันว่าคอมเมนต์ขึ้นบนโพสต์ Reload /posts/<slug ของโพสต์> query post(slug) ของ Post page ซึ่งรวมคอมเมนต์มาด้วย มี revalidate: 60 window เดียวกับ home list คอมเมนต์ที่เพิ่ง approve จึงอาจใช้เวลาอีกไม่เกิน 60 วินาทีถึงจะขึ้นที่นี่ ด้วยเหตุผลเดียวกับขั้นตอนที่ 4 เป๊ะ ๆ พอพ้น window นั้นแล้ว คอมเมนต์จะโผล่ในรายการ ตรงกับ Verify section ของ Moderation เป๊ะ ต่างกันแค่รอบนี้พิสูจน์ผ่าน admin UI จริงและ container stack จริง แทนที่จะเรียกตรงกับ Apollo Sandbox
แก้ปัญหาที่พบบ่อย
หัวข้อที่มีชื่อว่า “แก้ปัญหาที่พบบ่อย”CORS error ใน console ของ browser ที่เอ่ยถึง WEB_ORIGIN หรือ origin ที่ถูกบล็อก enableCors({ origin: configService.getOrThrow('WEB_ORIGIN'), ... }) จาก The app module ปฏิเสธ origin ไหนก็ตามที่ไม่ตรงกันเป๊ะ WEB_ORIGIN ใน .env ต้องเป็น http://localhost:3000 — origin ที่browserเห็นจริง ๆ ว่าแอปนี้รันอยู่ — ไม่ใช่ http://web:3000 (นั่นคือชื่อ container network ที่ไม่มีความหมายอะไรกับ browser) และไม่ใช่ http://localhost:4000 (นั่นคือ origin ของ API เอง ไม่ใช่ของ web app)
Server Components ดึงข้อมูลไม่สำเร็จ แต่หน้าเดียวกันทำงานได้ดีเวลา curl ตรงเข้าไปที่ port 4000 นี่คือการสลับด้านของ URL สองตัวจาก The web image โดยไม่ตั้งใจ เช็กว่า API_URL_INTERNAL ตั้งเป็น http://api:4000/graphql (ชื่อคอนเทนเนอร์) ไม่ใช่ http://localhost:4000/graphql เพราะข้างในคอนเทนเนอร์ web คำว่า localhost หมายถึงตัวคอนเทนเนอร์ web เอง ซึ่งไม่มีอะไร listen อยู่ที่ port 4000 เลย
Request ของ browser เองล้มเหลวด้วย error แบบ DNS (ERR_NAME_NOT_RESOLVED หรือคล้ายกัน) สำหรับ host อย่าง api อันนี้คือความผิดพลาดด้านตรงข้าม: NEXT_PUBLIC_API_URL ถูกฝัง (หรือ override) เป็น http://api:4000/graphql แต่ browser บนเครื่อง host ไม่ได้อยู่ใน Compose network จึง resolve api เป็น hostname ไม่ได้เลย มีแค่คอนเทนเนอร์บน network นั้นเท่านั้นที่ทำได้ ให้ rebuild web ด้วย --build-arg NEXT_PUBLIC_API_URL=http://localhost:4000/graphql และจำไว้จาก The web image ว่าค่านี้ inline ตอน build time แค่ restart คอนเทนเนอร์เฉย ๆ จึงไม่หยิบค่าใหม่มาใช้
api ไม่ถึง (healthy) เลย พร้อม Mongo authentication error ใน log แทบทุกครั้งเกิดจาก authSource=admin ที่หายไปหรือผิดใน MONGODB_URI Compose skeleton อธิบายเหตุผลไว้แล้ว: root user ที่ MONGO_INITDB_ROOT_USERNAME/PASSWORD สร้างขึ้นอยู่ใน database admin ของ Mongo ไม่ใช่ใน devblog ที่เป็นชื่อ database ที่อยู่ก่อนหน้าใน connection string เดียวกัน ให้เช็กว่า override ใน docker-compose.yml ยังเป็น mongodb://devblog:devblog@mongo:27017/devblog?authSource=admin ตรงตัว
infra/docker-compose.yml ตอนนี้รันทั้ง stack: mongo ไม่เปลี่ยนจาก Compose skeleton, api build จาก The API image และถูกกั้นไว้หลัง depends_on: mongo: condition: service_healthy เพื่อไม่ให้พยายามเชื่อมต่อก่อนที่ root user ของ Mongo จะมีอยู่จริง ส่วน web build จาก The web image ต่อสายเข้ากับทั้ง NEXT_PUBLIC_API_URL (address ของ api สำหรับ browser) และ API_URL_INTERNAL (address ของ service เดียวกันสำหรับ container network)
คำสั่งเดียว docker compose up --build คือเป้าหมายที่ทั้งโมดูลนี้มุ่งไปหา และการเดินระบบแบบ acceptance ก็พิสูจน์ว่าทุกชั้นของคอร์สทำงานร่วมกันได้เป็นครั้งแรก ตั้งแต่การลงทะเบียน, admin ที่ promote ด้วยมือ (ช่องว่างจริงที่คอร์สนี้เปิดทิ้งไว้), การเขียนและ publish โพสต์, cache window 60 วินาทีของ ISR ทั้งบน home list และหน้าโพสต์, การ submit คอมเมนต์แบบไม่ระบุตัวตน ไปจนถึงการ moderate โดย admin ส่วน Troubleshooting section ก็ระบุรูปแบบความล้มเหลวของแต่ละจุดไว้ตรง ๆ ทั้ง CORS, API URL สองตัว และ authSource ของ Mongo ที่ตั้งผิด แทนที่จะปล่อยให้ไปเจอเอาเองโดยบังเอิญ นี่คือการปิด Module 12 ทั้ง DevBlog stack รันได้จาก docker compose up --build คำสั่งเดียวแล้ว
ถัดไป: Wrap-up →